feat(grid): ajout algorithme score produit avec parametrage dynamique et page d'aide

This commit is contained in:
Michael committed 2026-02-21 04:00:51 +01:00
1 parent f540e4f2b3
commit 3544c0c898
67 files changed
+19476 -15

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@@ -0,0 +1,190 @@
---
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8]
workflowType: 'architecture'
lastStep: 8
status: 'complete'
completedAt: '2026-02-21'
inputDocuments: [
'c:\Users\Michael\Git\CollectFlow\_bmad-output\planning-artifacts\prd.md',
'c:\Users\Michael\Git\CollectFlow\_bmad-output\planning-artifacts\ux-design-specification.md'
]
project_name: 'CollectFlow'
user_name: 'Michael'
---
## Project Context Analysis
### Requirements Overview
**Functional Requirements:**
L'architecture doit supporter un workflow de décision tactique en 2 magasins (plus vue globale). Les piliers fonctionnels sont :
- **Sélection de périmètre** (Magasin/Fournisseur) pilotant le chargement des données.
- **Grille de données Ultra-Dense** (10 000+ lignes) avec historique 12 mois (Heatmap).
- **Moteur d'arbitrage en ligne** (A/B/C/Z) avec recalcul immédiat des KPIs financiers (CA, Marge).
- **Intégration OpenRouter** pour les préconisations IA et justifications textuelles.
- **Persistance par Snapshots** pour permettre un export Excel différentiel (Delta).
**Non-Functional Requirements:**
- **Performance** : Rendu fluide de la grille (virtualisation) et latence < 100ms pour les interactions métier.
- **Sécurité** : Proxy API pour OpenRouter et isolation des clés côté serveur uniquement.
- **Fiabilité** : Snapshots de session persistants pour éviter les pertes de données lors des sessions d'arbitrage massives.
**Scale & Complexity:**
- **Primary domain**: Retail Analytics & Decision Support (B2B).
- **Complexity level**: Medium-High (Volume de données tabulaires + Intégration IA + Logique de Snapshot).
- **Estimated architectural components**: Frontend Grid Service, Backend AI Proxy, Database Snapshot Manager, Excel Export Engine.
### Technical Constraints & Dependencies
- **Data Layer** : Base de données PostgreSQL avec un schéma existant (`ventes_produits`, vues matérialisées).
- **Frontend Constraints** : Besoin critique de virtualisation pour gérer la densité sans perte de frames.
- **Deployment** : Environnement Docker-orchestrated.
### Cross-Cutting Concerns Identified
- **State Management** : Gestion de l'état "Draft" (arbitrages non validés) vs État permanent.
- **Performance Monitoring** : Temps de réponse des appels IA et génération d'export.
- **Data Visualization** : Rendu performant de la Heatmap 12m par ligne.
## Starter Template Evaluation
### Primary Technology Domain
Dashboard Décisionnel Haute Performance (B2B SaaS / Retail Analytics) basé sur React.
### Selected Starter: Next.js (latest)
**Rationale for Selection:**
- **Proxy API Intégré** : Facilite la sécurisation des clés OpenRouter sans infrastructure backend séparée.
- **Server-Side logic** : Possibilité de déporter le calcul massif des KPIs et la gestion des Snapshots côté serveur.
- **Ecosystème Shadcn/Radix** : Intégration native parfaite avec la stack visuelle cible.
**Initialization Command:**
```bash
npx create-next-app@latest ./ --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --turbo
```
## Core Architectural Decisions
### Decision Priority Analysis
**Critical Decisions (Block Implementation):**
- Choix de Next.js comme socle full-stack.
- Utilisation de Drizzle ORM pour l'accès aux données PostgreSQL.
- Implémentation de TanStack Table v8 avec virtualisation pour la grille 10 000 lignes.
**Important Decisions (Shape Architecture):**
- Adoption de Zustand pour la gestion d'état "Draft" (sessions d'arbitrage).
- Utilisation de Next.js Server Actions pour une communication API type-safe.
- Sécurisation via Auth.js (NextAuth) v5.
**Deferred Decisions (Post-MVP):**
- Cache global (Redis) : À évaluer si les performances de PostgreSQL/Drizzle ne suffisent pas après l'ajout de 50 000+ lignes.
### Data Architecture
- **ORM** : Drizzle ORM (v0.45.1). Rationale : Performance brute et overhead minimal.
- **Validation** : Zod (v3.x). Rationale : Typage de bout en bout.
- **Persistence Strategy** : Snapshotting des états par session Fournisseur pour calcul de différentiel.
### Authentication & Security
- **Auth Solution** : Auth.js v5 (NextAuth). Pattern de session sécurisée.
- **Security Constraints** : API Proxy Backend obligatoire pour OpenRouter (NFR-SEC-2).
### API & Communication Patterns
- **Standard interactions** : Next.js Server Actions (Typage Fort avec Zod).
- **Heavy lifting** : Route Handlers (`/api/ai/analyze`, `/api/export/excel`) pour les traitements longs.
### Frontend Architecture
- **Grid Engine** : TanStack Table v8 + `@tanstack/react-virtual`.
- **State Store** : Zustand. Pattern : Store atomique par domaine (Grid, Context, Summary).
- **Data Fetching** : TanStack Query v5.
## Implementation Patterns & Consistency Rules
### Naming Patterns
- **Database** : `snake_case`.
- **Code/API** : `camelCase` (Drizzle s'occupe du mapping).
- **Components** : `PascalCase.tsx` (ex: `HeatmapGrid.tsx`).
- **Hooks/Utils** : `kebab-case.ts` (ex: `use-arbitrage-logic.ts`).
### Structure & Process
- **Architecture** : Feature-Driven in `src/features/`.
- **State** : Store atomique par feature via Zustand.
- **API** : Server Actions avec retour `{ success, data, error }`.
- **Tests** : Co-located `[Name].test.tsx`.
## Project Structure & Boundaries
### Complete Project Directory Structure
```text
CollectFlow/
├── public/ # Assets statiques (Logos, Icons)
├── src/
│ ├── app/ # Next.js App Router (Routes & Layouts)
│ │ ├── (auth)/ # Routes d'authentification (Epic 1)
│ │ ├── (dashboard)/ # Cœur de l'application (Epic 2 & 3)
│ │ │ └── grid/ # Page principale de la grille
│ │ └── api/ # Route Handlers (IA Proxy, Export Excel)
│ ├── components/
│ │ ├── ui/ # Composants Shadcn (Boutons, Inputs, Toast)
│ │ └── shared/ # Composants transverses (Layout, Sidebar)
│ ├── features/ # Logique Métier par Domaine (Isolation Agents)
│ │ ├── grid/ # [Epic 2 & 3] Composants Heatmap, Store Zustand
│ │ ├── ai-copilot/ # [Epic 4] Intégration OpenRouter, Insights UI
│ │ ├── snapshots/ # [Epic 5] Logique de persistence et Delta
│ │ └── export/ # [Epic 5] Générateur Excel Différentiel
│ ├── db/ # Configuration Drizzle, Schéma, Migrations
│ ├── lib/ # Clients partagés (Auth.js, OpenRouter, Utils)
│ └── types/ # Definitions TypeScript globales
├── .env.local # Secrets (DB_URL, OPENROUTER_KEY)
├── package.json
└── tailwind.config.ts
```
### Architectural Boundaries
**API Boundaries:**
- Les **Server Actions** gèrent 90% des mutations métier.
- Les **Route Handlers** sont réservés aux flux externes (IA) et formats binaires (Excel).
**Data Boundaries:**
- Drizzle ORM possède la source de vérité du schéma (`src/db/schema.ts`).
- Les données "Draft" vivent exclusivement dans les stores **Zustand** avant validation.
### Requirements to Structure Mapping
**Epic/Feature Mapping:**
- **Epic 1 (Fondations)** : `src/db/`, `src/app/(auth)/`, `src/lib/auth.ts`.
- **Epic 2 (Vue Grid)** : `src/features/grid/components/`, `src/features/grid/store/`.
- **Epic 3 (Arbitrage)** : `src/features/grid/hooks/use-arbitrage.ts`.
- **Epic 4 (IA)** : `src/features/ai-copilot/`, `src/app/api/ai/`.
- **Epic 5 (Snapshots & Export)** : `src/features/snapshots/`, `src/features/export/`.
**Cross-Cutting Concerns:**
- **Authentication** : Protect via `src/middleware.ts` and `src/lib/auth.ts`.
- **Visual Theme** : Centralisé dans `tailwind.config.ts` et `src/app/globals.css`.
## Architecture Validation Results
### Coherence Validation ✅
- **Compatibilité** : Stack Next.js + Drizzle + TanStack + Auth.js 100% compatible.
- **Cohérence** : Les patterns de nommage (snake_case DB / camelCase App) sont standardisés via Drizzle mapping.
- **Alignement** : La structure feature-driven supporte l'isolation des épiques.
### Requirements Coverage Validation ✅
- **Fonctionnel** : Chaque épique (1-5) possède une zone d'implémentation définie.
- **NFR-PERF** : Virtualisation validée comme pattern critique pour la grille.
- **NFR-SEC** : Proxy IA validé pour la protection des secrets OpenRouter.
### Implementation Readiness Validation ✅
- **Statut** : READY FOR IMPLEMENTATION.
- **Confiance** : Élevée (9/10).
- **Forces** : Isolation métier, stack moderne 2026, gestion sécurisée de l'IA.
### Architecture Completeness Checklist
- [x] Analyse de contexte et complexité
- [x] Stack technologique spécifiée (Next.js 16+, Drizzle 0.45)
- [x] Patterns de nommage et structure feature-driven
- [x] Mapping des 5 Épiques vers la structure physique
- [x] Validation de la conformité NFR (Perf, Sec, Rel)
### Implementation Handoff
1. Initialiser le projet via la commande `npx create-next-app@latest`.
2. Configurer le schéma Drizzle basé sur `database-schema.sql`.
3. Démarrer l'implémentation par l'**Epic 1 (Fondations & Connectivité)**.
+371
View File
@@ -0,0 +1,371 @@
---
stepsCompleted: [step-01-validate-prerequisites]
inputDocuments: [
'c:\Users\Michael\Git\CollectFlow\_bmad-output\planning-artifacts\prd.md',
'c:\Users\Michael\Git\CollectFlow\_bmad-output\planning-artifacts\ux-design-specification.md'
]
---
# CollectFlow - Epic Breakdown
## Overview
This document provides the complete epic and story breakdown for CollectFlow, decomposing the requirements from the PRD, UX Design if it exists, and Architecture requirements into implementable stories.
## Requirements Inventory
### Functional Requirements
FR1: L'Acheteur peut sélectionner le périmètre d'analyse (Magasin 1, Magasin 2, ou Global).
FR2: L'Acheteur peut sélectionner le fournisseur à analyser.
FR3: L'Acheteur peut consulter la liste complète des produits actifs pour le périmètre et fournisseur sélectionnés, issue de la base PostgreSQL.
FR4: L'Acheteur peut visualiser la quantité vendue, le Chiffre d'Affaires et la marge générée par mois (jusqu'à 6 mois d'historique) pour chaque produit.
FR5: L'Acheteur peut identifier visuellement un premier score de performance "Basique" (Volume vs Marge) pré-calculé sans IA.
FR6: L'Acheteur peut définir la capacité cible (nombre d'articles) pour chaque gamme (A, B, C) par fournisseur et magasin.
FR7: L'Acheteur peut déclencher manuellement une analyse IA via un bouton d'action dédié.
FR8: L'Application soumet les KPIs (Quantité, CA, Marge) au modèle IA (via OpenRouter) suite à ce déclenchement, et affiche la proposition de gamme (A, B, C, Z) pour chaque produit.
FR9: L'Acheteur peut visualiser de façon comparative la gamme actuelle et la proposition algorithmique/IA pour chaque produit.
FR10: L'Acheteur peut attribuer manuellement de façon individuelle (surcharge) la gamme finale d'un produit (A, B, C, Z).
FR11: L'Acheteur peut attribuer une gamme spécifique en masse (Bulk Action) à une sélection de produits.
FR12: L'Acheteur peut annuler les modifications non sauvegardées de sa session en cours et revenir au dernier état validé.
FR13: L'Acheteur peut prévisualiser un résumé global des changements de gammes (le delta) avant de confirmer.
FR14: L'Acheteur peut enregistrer et valider définitivement les choix de gammes effectués. (Création d'un Snapshot persistant en base).
FR15: L'Acheteur peut générer un fichier d'export au format Excel des données validées. Ce fichier contient uniquement le delta par rapport à la session précédente.
FR16: Le système formate techniquement l'export Excel selon la structure syntaxique attendue par le logiciel de gestion de magasin cible.
FR17: L'Application peut être déployée et exécutée dans un environnement Docker.
FR18: L'Administrateur peut configurer les paramètres de connexion à la base de données PostgreSQL via des variables d'environnement.
FR19: L'Administrateur peut configurer la connexion à l'IA (Clé API OpenRouter, Modèle) via des variables d'environnement.
FR20: L'Utilisateur peut s'authentifier de manière sécurisée (connexion B2B).
### NonFunctional Requirements
NFR-PERF-1: Le tableau de bord initial (liste des produits) s'affiche en moins de 3s pour 10 000 lignes.
NFR-PERF-2: Les modifications de gamme "en ligne" sont répercutées visuellement en moins de 100ms.
NFR-PERF-3: Les réponses de l'IA (OpenRouter) s'affichent en moins de 10s.
NFR-SEC-1: Authentification avec mots de passe forts (min 12 car., complexité).
NFR-SEC-2: Clés API et identifiants DB jamais exposés côté client (Backend uniquement via env vars).
NFR-SEC-3: Trafic HTTPS/TLS 1.2 minimum.
NFR-INT-1: Export Excel strictement conforme à l'encodage et au nommage du logiciel cible.
NFR-REL-1: Système de Snapshot garantissant la non-altération des états historiques si crash.
### Additional Requirements (from UX Specification)
- **Layout 32" Fluide** : Le design s'adapte à 100% de la largeur des écrans larges (XL > 1440px).
- **Grille "Ultra-Dense"** : Nomenclature, Historique M1-M12, Total Qté 12m, et Marge (€/%) visibles sur une seule ligne.
- **Heatmap Intelligence** : Coloration conditionnelle des cellules de vente (M1-M12) et badge couleur pour la marge.
- **Auto-Jump Logic** : Passage automatique à la référence suivante après un arbitrage manuel.
- **Sticky Headers** : Colonnes d'en-tête et résumé financier (Summary Bar) fixes au scroll.
- **Tabular Numbers** : Utilisation de polices à espacement fixe (`JetBrains Mono`) pour l'alignement décimal des données financières.
- **Zero-Learning UX** : Interface tabulaire épurée sans pop-ups intrusifs, édition "Inline" privilégiée.
### FR Coverage Map
- **FR1 (Sélection Périmètre)**: Epic 2
- **FR2 (Sélection Fournisseur)**: Epic 2
- **FR3 (Liste Produits)**: Epic 2
- **FR4 (KPIs Mensuels)**: Epic 2
- **FR5 (Pre-score Basique)**: Epic 2
- **FR6 (Capacité Cible)**: Epic 3
- **FR7 (Déclenchement IA)**: Epic 4
- **FR8 (Analyse IA)**: Epic 4
- **FR9 (Vue Comparative)**: Epic 4
- **FR10 (Surcharge Individuelle)**: Epic 3
- **FR11 (Actions Bulk)**: Epic 3
- **FR12 (Annulation)**: Epic 3
- **FR13 (Prévisualisation Delta)**: Epic 3
- **FR14 (Snapshot & Validation)**: Epic 5
- **FR15 (Génération Export)**: Epic 5
- **FR16 (Formatage ERP)**: Epic 5
- **FR17 (Dockerization)**: Epic 1
- **FR18 (Config PostgreSQL)**: Epic 1
- **FR19 (Config AI/OpenRouter)**: Epic 1
- **FR20 (Authentification B2B)**: Epic 1
**NFR Mapping:**
- **Performance (Grid)**: Epic 2
- **Réactivité (Inline)**: Epic 3
- **Sécurité & API**: Epic 1
- **Fidélité Export**: Epic 5
- **Fiabilité (Snapshot)**: Epic 5
## Epic List
## Epic 1: Fondations & Connectivité
Mise en place de l'environnement technique, de la base de données et de l'accès sécurisé pour permettre le démarrage du projet.
### Story 1.1: Environnement Docker & Orchestration
As an Administrator,
I want to manage the application architecture within Docker containers,
So that I can ensure a reproducible and isolated environment for development and production.
**Acceptance Criteria:**
**Given** a project directory with a Docker configuration
**When** I run `docker-compose up`
**Then** three containers (Frontend, Backend, PostgreSQL) start successfully
**And** they can communicate internally through a dedicated Docker network.
### Story 1.2: Configuration Base de Données & Environnement
As a Developer,
I want to connect the application to a PostgreSQL database using environment variables,
So that I can secure credentials and separate configuration from code.
**Acceptance Criteria:**
**Given** the database container is running
**When** the backend initializes with the provided DB_URL variable
**Then** it successfully retrieves data from the 'configuration' table
**And** the initial schema from `docs/database-schema.sql` is applied.
### Story 1.3: Authentification B2B Sécurisée
As an Buyer,
I want to log in to the application with my credentials,
So that my professional data and decision-making environment are secured.
**Acceptance Criteria:**
**Given** the application is running on its domain
**When** I enter a valid email and a strong password (>12 characters)
**Then** I am redirected to the Dashboard
**And** session tokens are managed securely according to NFR-SEC-1.
### Story 1.4: Services IA (OpenRouter)
As a Developer,
I want a dedicated backend service to communicate with the OpenRouter API,
So that AI requests are centralized and the API key is never exposed to the client.
**Acceptance Criteria:**
**Given** the OpenRouter API key is set in environment variables
**When** the backend service sends a test prompt to a specified model
**Then** it receives a valid JSON response from the AI
**And** no API tokens are leaked in the client-side SPA bundle (NFR-SEC-2).
## Epic 2: Exploration de Performance (Vue Grid)
Permettre à l'acheteur de sélectionner un périmètre et de visualiser les performances réelles (ventes, CA, marge) via la grille ultra-dense.
### Story 2.1: Sélecteur de Contexte (Magasin/Fournisseur)
As a Buyer,
I want to select a specific store and provider from a sidebar,
So that I can focus my analysis on a precise subset of my collection.
**Acceptance Criteria:**
**Given** the application is loaded
**When** I select "Magasin 1" and provider "Fournisseur X" in the sidebar
**Then** the dashboard triggers a data fetch for this specific context
**And** the UI displays the currently active filters clearly.
### Story 2.2: Grille de Performance Ultra-Dense (Fondation)
As a Buyer,
I want to scroll through thousands of product lines without lag on my 32" screen,
So that I can maintain a high-speed professional workflow.
**Acceptance Criteria:**
**Given** a dataset of 10,000 product references
**When** I scroll the main data grid
**Then** the rendering remains fluid (using virtualization)
**And** the column headers remain fixed to the top (Sticky Headers).
### Story 2.3: Historique de Ventes & Heatmap 12m
As a Buyer,
I want to see a 12-month rolling sales history visualized as a heatmap,
So that I can immediately identify seasonality or recent stockouts.
**Acceptance Criteria:**
**Given** historical sales data in the database
**When** the grid renders a product line
**Then** it displays 12 columns with Month/Year labels (e.g., "Jan 25")
**And** the background color of each cell varies from gray (0) to deep blue based on quantity sold.
### Story 2.4: Cellules Financières & Pre-scoring (Marge & CA)
As a Buyer,
I want to see precise financial KPIs (Turnover, Margin) in a highly readable format,
So that I can make arbitrage decisions based on real profitability.
**Acceptance Criteria:**
**Given** calculated financial metrics
**When** the grid displays the Margin and Turnover columns
**Then** it uses `JetBrains Mono` for perfect vertical decimal alignment
**And** the margin percentage is highlighted (e.g., Emerald badge for >45%, Rose for <20%).
## Epic 3: Arbitrage Tactique & Supervision Humaine
Permettre à l'acheteur d'ajuster manuellement l'assortiment, d'utiliser le bulk-editing et de voir l'impact financier en temps réel.
### Story 3.1: Définition des Capacités Cibles
As a Buyer,
I want to set the target number of items for categories A, B, and C,
So that I can align my assortment decisions with my shelf capacity and budget.
**Acceptance Criteria:**
**Given** the provider analysis page is open
**When** I enter target quantities for Gamme A, B, and C in the configuration header
**Then** the values are saved for the current session
**And** the summary bar uses these targets to calculate the current "fill rate" (e.g., 80/100 items allocated).
### Story 3.2: Arbitrage "Inline" & Auto-Jump
As a Buyer,
I want to change a product's status (A, B, C, Z) directly in the table row and move to the next item automatically,
So that I can process hundreds of products at high speed without interruption.
**Acceptance Criteria:**
**Given** a list of products in the grid
**When** I select a new status (e.g., "Gamme Z") from the dropdown menu in a row
**Then** the status is updated instantly with a visual highlight
**And** the focus (or active row indicator) automatically moves to the next product line.
### Story 3.3: Barre de Résumé Flottante & Recalcul Temps Réel
As a Buyer,
I want to see the cumulative impact of my choices on Turnover and Margin updated in real-time,
So that I can ensure my final collection meets my financial performance goals.
**Acceptance Criteria:**
**Given** the floating summary bar is visible at the bottom of the screen
**When** I modify a product's category (A/B/C/Z)
**Then** the totals for Quantity, Turnover, and Margin in the bar update in less than 100ms
**And** the numbers pulse or animate slightly to acknowledge the change.
### Story 3.4: Actions en Masse (Bulk Actions) & Annulation
As a Buyer,
I want to apply a status to multiple selected products or reset all unconfirmed changes,
So that I can quickly handle large batches of items and recover from mistakes.
**Acceptance Criteria:**
**Given** multiple products are selected via checkboxes
**When** I choose "Assign to Gamme A" from the bulk action menu
**Then** all selected products are updated simultaneously
**And** clicking "Reset Session" reverts all products to their state at the start of the session.
## Epic 4: Assistance IA & Aide à la Décision
Déclencher l'intelligence algorithmique pour obtenir des recommandations de gammes basées sur OpenRouter.
### Story 4.1: Génération de Prompt & Envoi des KPIs
As a Buyer,
I want to trigger an AI analysis manually for a specific provider,
So that I can receive intelligent sorting suggestions based on the most recent sales performance data.
**Acceptance Criteria:**
**Given** the provider's grid is loaded with product KPIs
**When** I click the "Trigger AI Analysis" button
**Then** the system packages the Turnover, Margin, and Quantity data for all products in the filtered view
**And** sends this payload to the backend for prompt generation.
### Story 4.2: Traitement IA & Réception des Suggestions (OpenRouter)
As a Developer,
I want the system to parse and map the AI response from OpenRouter back to the product list,
So that recommandations are correctly associated with their respective references.
**Acceptance Criteria:**
**Given** the backend has received an AI response (JSON) from OpenRouter
**When** the response is returned to the frontend
**Then** each product ID is mapped to its proposed category (A, B, C, or Z)
**And** the entire process completes in less than 10 seconds (NFR-PERF-3).
### Story 4.3: Vue Comparative "Actuel vs IA"
As a Buyer,
I want to see the AI's proposed category right next to the current category,
So that I can immediately spot and audit the recommended changes.
**Acceptance Criteria:**
**Given** AI recommendations have been received
**When** the grid renders
**Then** a new column "Proposition IA" is displayed
**And** cells where the AI suggestion differs from the current status are visually highlighted.
### Story 4.4: Affichage des "AI Insights" (Commentaire Contextuel)
As a Buyer,
I want to read a short justification for each AI recommendation,
So that I can understand the context (e.g., stockouts, margin trends) before approving a category change.
**Acceptance Criteria:**
**Given** the AI has provided textual justifications (insights)
**When** I hover over an AI recommendation or view the product details
**Then** a compact text block is displayed explaining the reasoning (e.g., "High volume but declining margin, downgrade to B")
**And** the insight is clearly legible and contextually relevant.
## Epic 5: Clôture, Snapshots & Export
Sécuriser les décisions via des snapshots de session et générer l'export Excel différentiel pour l'ERP.
### Story 5.1: Résumé Final & Prévisualisation du Delta
As a Buyer,
I want to see a global summary of my assortment changes before finalizing the session,
So that I can verify the financial impact and the number of products categorised as 'Z' (Removed).
**Acceptance Criteria:**
**Given** I have completed my arbitrage for a provider
**When** I click "Finalize Session"
**Then** a summary screen appears showing the total count of items in categories A, B, C, and Z
**And** it highlights the total Turnover and Margin impact of the session's diff (delta).
### Story 5.2: Validation de Session & Snapshot Persistant
As a Developer,
I want the system to save the current state of the assortment as a permanent Snapshot in the database,
So that I can use it as a reference for calculating deltas in future sessions.
**Acceptance Criteria:**
**Given** the user confirms the final summary
**When** the "Save Assortment" action is triggered
**Then** a new record is created in the database with the current category status for all products
**And** the current session is marked as "Validated".
### Story 5.3: Génération de l'Export Excel Différentiel
As a Buyer,
I want to download an Excel file containing only the products whose category has changed compared to the previous season,
So that I can import it directly into my ERP without manual filtering.
**Acceptance Criteria:**
**Given** a validated session with recorded snapshots
**When** I click "Generate Export"
**Then** the system identifies lines where the current category differs from the last Snapshot
**And** it generates an Excel file only containing these specific rows.
### Story 5.4: Formatage ERP & Encodage (UTF-8)
As an Administrator,
I want the exported Excel file to strictly follow the ERP's required syntax and encoding,
So that it can be imported without syntax errors or data corruption.
**Acceptance Criteria:**
**Given** the differential data is ready for export
**When** the Excel file is generated
**Then** it uses UTF-8 encoding
**And** the column headers and data types match exactly the skeleton provided in the target ERP requirements (NFR-INT-1).
+88 -15
View File
@@ -1,5 +1,5 @@
--- ---
stepsCompleted: ['step-01-init', 'step-02-discovery', 'step-02b-vision', 'step-02c-executive-summary', 'step-03-success', 'step-04-journeys', 'step-05-domain', 'step-06-innovation', 'step-07-project-type'] stepsCompleted: ['step-01-init', 'step-02-discovery', 'step-02b-vision', 'step-02c-executive-summary', 'step-03-success', 'step-04-journeys', 'step-05-domain', 'step-06-innovation', 'step-07-project-type', 'step-08-scoping', 'step-09-functional', 'step-10-nonfunctional', 'step-11-polish']
inputDocuments: [] inputDocuments: []
documentCounts: documentCounts:
briefs: 0 briefs: 0
@@ -31,12 +31,12 @@ CollectFlow se distingue par son approche paramétrique adaptative couplée à u
- **Paramétrage Dynamique par Fournisseur :** Contrairement aux systèmes rigides, la capacité des gammes (A, B, C) n'est pas fixe. L'utilisateur définit le volume de références cibles par fournisseur et par magasin (ex: 400 références pour la Gamme A chez le Fournisseur X). - **Paramétrage Dynamique par Fournisseur :** Contrairement aux systèmes rigides, la capacité des gammes (A, B, C) n'est pas fixe. L'utilisateur définit le volume de références cibles par fournisseur et par magasin (ex: 400 références pour la Gamme A chez le Fournisseur X).
- **Intelligence du Cycle de Vie (Gamme Z) :** Au lieu de simplement supprimer les produits non performants, l'application les classe en "Gamme Z" (abandon). Cela permet de conserver l'historique d'analyse pour éviter de reproduire de mauvais choix d'achat futurs, tout en nettoyant les vues actives. Le système inclut un export natif au format Excel pour les gammes cibles, permettant la réintégration fluide et immédiate des décisions d'assortiment dans le logiciel de gestion de magasin existant. - **Intelligence du Cycle de Vie (Gamme Z) :** Au lieu de simplement supprimer les produits non performants, l'application les classe en "Gamme Z" (abandon). Cela permet de conserver l'historique d'analyse pour éviter de reproduire de mauvais choix d'achat futurs, tout en nettoyant les vues actives. Le système inclut un export natif au format Excel pour les gammes cibles, permettant la réintégration fluide et immédiate des décisions d'assortiment dans le logiciel de gestion de magasin existant.
## Project Classification ## Project Characteristics
- **Type:** Web Application (Single Page Application - SPA)
- **Project Type:** Web Application - **Domain:** Retail Analytics & Inventory Management (B2B Internal)
- **Domain:** Retail Analytics & Inventory Management - **Environment:** Docker-orchestrated, Google Chrome (Desktop) optimized
- **Complexity:** Medium (Nécessite des agrégations de données poussées, vues matérialisées PostgreSQL, architecture multi-magasins/multi-fournisseurs) - **Complexity:** Medium (Data aggregation via PostgreSQL materialized views, multi-store architecture, OpenRouter AI integration). *Note: The core data structure is defined in [`docs/database-schema.sql`](file:///c:/Users/Michael/Git/CollectFlow/docs/database-schema.sql).*
- **Project Context:** Greenfield (Nouveau projet) - **Context:** Greenfield
## Success Criteria ## Success Criteria
@@ -134,15 +134,88 @@ Pour valider cette approche, il faudra tester l'interface avec l'acheteur repré
Le risque principal d'une vue tabulaire métier est la surcharge visuelle (le syndrome de "l'usine à gaz") au fur et à mesure de l'ajout de nouvelles données (N-1, indicateurs supplémentaires). Le risque principal d'une vue tabulaire métier est la surcharge visuelle (le syndrome de "l'usine à gaz") au fur et à mesure de l'ajout de nouvelles données (N-1, indicateurs supplémentaires).
*Mitigation :* Maintenir une discipline de conception stricte (Progressive Disclosure). Les informations secondaires doivent n'être révélées qu'à la demande (ex: clic sur une ligne pour voir le détail des 12 mois) afin de conserver un tableau principal ultra-épuré et dédié à l'action. *Mitigation :* Maintenir une discipline de conception stricte (Progressive Disclosure). Les informations secondaires doivent n'être révélées qu'à la demande (ex: clic sur une ligne pour voir le détail des 12 mois) afin de conserver un tableau principal ultra-épuré et dédié à l'action.
## Web Application Specific Requirements
### Project-Type Overview ## Project Scoping & Phased Development
CollectFlow est conçue comme une **Single Page Application (SPA)** interne. Ce format garantit une expérience analytique fluide et sans rechargement pour l'Acheteur. ### MVP Strategy & Philosophy
### Technical Architecture Considerations **MVP Approach:** Problem-Solving MVP (Se concentrer sur la résolution du problème principal : trier rapidement l'assortiment avec des données fiables et générer un fichier d'export actionnable immédiatement, sans perturber le système existant plus que nécessaire). L'approche est minimaliste sur le front-end mais robuste sur le back-end.
- **Single Page Application (SPA) :** L'architecture front-end doit permettre la manipulation immédiate (tris, actions par lot, modifications de gammes en ligne) d'un large volume de données sans latence ni rafraîchissement global de l'écran. **Resource Requirements:** Une petite équipe technique (1 Développeur Full-Stack maîtrisant React/PostgreSQL) avec l'Acheteur en tant qu'expert métier pour valider l'UX et les règles de gestion (1 à 2 jours par semaine d'implication).
- **Cible Navigateur :** Le développement et le support sont optimisés exclusivement pour **Google Chrome (Desktop)**, reflétant les standards des postes de travail des équipes d'achat.
- **Data en Temps Réel :** Contrairement aux systèmes de batching nocturnes locaux, l'application doit s'appuyer sur des bases de données de caisse "en live" ou synchronisées en temps réel pour afficher l'état instantané des encaissements lorsqu'une collection est révisée. ### MVP Feature Set (Phase 1)
- **Stratégie SEO & Accessibilité :** S'agissant d'un outil fermé B2B, le référencement (SEO) est inactif. L'interface (UI/UX) doit toutefois obéir à des règles de fort contraste pour éviter la fatigue visuelle face à la densité des tableaux de bord (Accessibilité Cognitive).
**Core User Journeys Supported:**
- La Revue Stratégique de Collection (Filtrage Périmètre/Fournisseur, Affichage des performances sur 6 mois, Modification en ligne des gammes (A, B, C, Z), Forçage manuel).
- Export Différentiel vers Excel (Uniquement les modifications).
**Must-Have Capabilities:**
- Interface tabulaire "Zero-Learning" avec édition "inline" des menus déroulants.
- Algorithme de tri basique (Score Volume + Marge).
- Gestion de session simple pour identifier le "Delta" (Snapshotting) lors de l'export.
- Connexion sécurisée de base (B2B).
### Post-MVP Features
**Phase 2 (Growth):**
- Historisation sur 12 mois glissants et l'ajout de la comparaison N-1.
- Actions en masse (Bulk Actions) avancées (ex: appliquer une gamme à toute une catégorie de produits d'un coup).
- Raffinement de l'algorithme de calcul des vues matérialisées pour des temps de réponse sub-secondes si le volume de données augmente.
**Phase 3 (Expansion):**
- Analyse quantitative de l'"effet de Halo" et de la cannibalisation (intelligence analytique plus poussée).
- API de synchronisation bidirectionnelle directe avec le logiciel de gestion cible (éliminer l'export Excel).
### Risk Mitigation Strategy
**Technical Risks:** La performance du rendu du grand tableau côté client (SPA) et la génération de l'export Excel complexe. Mitigation : Utiliser la virtualisation (ex: react-window) pour le front-end. Créer des Preuves de Concept (PoC) précoces pour l'export Excel.
**Market Risks:** Rejet par l'Acheteur face à une nouvelle interface, même simple. Mitigation : Tests d'utilisabilité en continu dès les premières maquettes filaires. Validation stricte du "Zero-Learning".
**Resource Risks:** Indisponibilité de l'expert métier (Acheteur) pour valider les règles de l'IA. Mitigation : Développer avec des jeux de données d'essai anonymisés massifs dès le départ pour une validation asynchrone sécurisée.
## Functional Requirements
### 1. Perimeter & Context Selection
- **FR1**: L'Acheteur peut sélectionner le périmètre d'analyse (Magasin 1, Magasin 2, ou Global).
- **FR2**: L'Acheteur peut sélectionner le fournisseur à analyser.
### 2. Performance Dashboard
- **FR3**: L'Acheteur peut consulter la liste complète des produits actifs pour le périmètre et fournisseur sélectionnés, issue de la base PostgreSQL.
- **FR4**: L'Acheteur peut visualiser la quantité vendue, le Chiffre d'Affaires et la marge générée par mois (jusqu'à 6 mois d'historique) pour chaque produit.
- **FR5**: L'Acheteur peut identifier visuellement un premier score de performance "Basique" (Volume vs Marge) pré-calculé sans IA.
### 3. AI-Assisted Tactical Management
- **FR6**: L'Acheteur peut définir la capacité cible (nombre d'articles) pour chaque gamme (A, B, C) par fournisseur et magasin.
- **FR7**: **L'Acheteur peut déclencher manuellement une analyse IA via un bouton d'action dédié.**
- **FR8**: L'Application soumet les KPIs (Quantité, CA, Marge) au modèle IA (via OpenRouter) suite à ce déclenchement, et affiche la proposition de gamme (A, B, C, Z) pour chaque produit.
- **FR9**: L'Acheteur peut visualiser de façon comparative la gamme actuelle et la proposition algorithmique/IA pour chaque produit.
- **FR10**: L'Acheteur peut attribuer manuellement de façon individuelle (surcharge) la gamme finale d'un produit (A, B, C, Z).
- **FR11**: L'Acheteur peut attribuer une gamme spécifique en masse (Bulk Action) à une sélection de produits.
- **FR12**: **L'Acheteur peut annuler les modifications non sauvegardées de sa session en cours et revenir au dernier état validé.**
### 4. Export & Integration
- **FR13**: **L'Acheteur peut prévisualiser un résumé global des changements de gammes (le delta) avant de confirmer.**
- **FR14**: L'Acheteur peut enregistrer et valider définitivement les choix de gammes effectués. (Création d'un Snapshot persistant en base).
- **FR15**: L'Acheteur peut générer un fichier d'export au format Excel des données validées. Ce fichier contient *uniquement* le delta par rapport à la session précédente.
- **FR16**: Le système formate techniquement l'export Excel selon la structure syntaxique attendue par le logiciel de gestion de magasin cible.
### 5. System & Configuration (Infrastructure)
- **FR17**: L'Application peut être déployée et exécutée dans un environnement **Docker**.
- **FR18**: L'Administrateur peut configurer les paramètres de connexion à la base de données PostgreSQL via des variables d'environnement.
- **FR19**: L'Administrateur peut configurer la connexion à l'IA (Clé API OpenRouter, Modèle) via des variables d'environnement.
- **FR20**: L'Utilisateur peut s'authentifier de manière sécurisée (connexion B2B).
## Non-Functional Requirements
### Performance
- **NFR-PERF-1 :** Le tableau de bord initial (liste des produits d'un fournisseur) doit s'afficher en moins de 3 secondes pour un jeu de données contenant jusqu'à 10 000 lignes.
- **NFR-PERF-2 :** Les modifications de gamme "en ligne" (inline editing) par l'Acheteur doivent être répercutées visuellement dans l'interface en moins de 100 millisecondes (ressenti immédiat).
- **NFR-PERF-3 :** Les réponses de l'IA (via OpenRouter) suite à une demande d'analyse de gamme doivent s'afficher en moins de 10 secondes.
### Security
- **NFR-SEC-1 :** L'authentification de l'Acheteur nécessite des mots de passe forts (minimum 12 caractères, incluant majuscules, minuscules, chiffres, caractères spéciaux).
- **NFR-SEC-2 :** Les clés de l'API OpenRouter et les identifiants de la base de données PostgreSQL ne doivent jamais être exposés côté client (SPA) et doivent être gardés exclusivement côté serveur via des variables d'environnement.
- **NFR-SEC-3 :** L'ensemble du trafic entre le client et le serveur doit utiliser une connexion sécurisée (HTTPS/TLS 1.2 minimum).
### Integration & Reliability
- **NFR-INT-1 :** Le fichier d'export Excel produit *doit* respecter strictement l'encodage (ex: UTF-8) et le nommage de colonnes (sans coquille) imposés par les contraintes du logiciel cible.
- **NFR-REL-1 :** Le système de `Snapshot` (FR14) doit garantir que lors d'un crash du navigateur ou d'une perte réseau, l'utilisateur ne perde que les actions de sa session active (non validées), sans altération des états historiques.
@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html lang="fr" class="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CollectFlow - UX Full Width UI (Mode Final)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #f8fafc;
}
.dark body {
background-color: #0f172a;
}
.font-mono-nums {
font-family: 'JetBrains Mono', monospace;
}
/* Grid definitions for full-width fluid layout */
.data-grid {
display: grid;
grid-template-columns: 2.5fr 1fr minmax(400px, 4fr) 0.6fr 0.8fr 1fr 0.4fr 0.6fr 3fr 1.2fr;
gap: 0.5rem;
align-items: center;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
</style>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { brand: { 50: '#f0f9ff', 100: '#e0f2fe', 500: '#0ea5e9', 600: '#0284c7', 900: '#0c4a6e' } } } }
}
function toggleTheme() { document.documentElement.classList.toggle('dark'); }
</script>
</head>
<body class="text-slate-900 dark:text-slate-200 h-screen flex flex-col transition-colors duration-200 overflow-hidden">
<!-- Header Navigation -->
<header
class="bg-white dark:bg-slate-950 border-b border-gray-200 dark:border-slate-800 p-4 flex justify-between items-center shrink-0">
<div class="flex items-center space-x-6">
<h1 class="text-xl font-semibold tracking-tight">CollectFlow <span
class="text-slate-400 text-sm font-normal uppercase tracking-widest">Génération Assortiment</span>
</h1>
<span
class="text-xs text-brand-600 dark:text-brand-400 font-bold px-3 py-1 bg-brand-50 dark:bg-brand-900/30 rounded-full border border-brand-200 dark:border-brand-800 shadow-sm">
Direction Validée • Ultra-Dense 32"
</span>
</div>
<button onclick="toggleTheme()"
class="p-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<svg class="w-5 h-5 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
<svg class="w-5 h-5 hidden dark:block text-yellow-300" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z">
</path>
</svg>
</button>
</header>
<main class="flex-1 flex flex-col p-4 space-y-4 overflow-hidden">
<!-- Context Header -->
<div class="flex justify-between items-end w-full">
<div
class="flex space-x-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 p-3 rounded-lg shadow-sm items-center w-full max-w-5xl">
<span
class="text-sm text-slate-500 uppercase tracking-widest border-r border-slate-200 dark:border-slate-700 pr-4">Fournisseur
Actif:</span>
<span
class="text-lg font-bold text-brand-700 dark:text-brand-400 border-r border-slate-200 dark:border-slate-700 pr-4">NIKE
EUROPE BV</span>
<span
class="text-sm text-slate-500 font-medium flex items-center pr-4 border-r border-slate-200 dark:border-slate-700">
<svg class="w-5 h-5 mr-2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z">
</path>
</svg>
Toutes Familles (Vue Globale)
</span>
<span class="text-sm bg-slate-100 dark:bg-slate-800 px-3 py-1 rounded-md font-mono-nums font-bold">1 240
Réf.</span>
</div>
<button
class="text-sm bg-brand-600 text-white px-5 py-2.5 rounded-lg shadow-md hover:bg-brand-500 transition font-bold active:scale-95">
+ Créer un Groupe Famille
</button>
</div>
<!-- Table Container -->
<div
class="flex-1 bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg shadow-sm flex flex-col w-full overflow-hidden">
<!-- Header Grid -->
<div
class="data-grid border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/50 p-3 text-[11px] font-bold text-slate-500 tracking-widest items-end uppercase shrink-0">
<div class="px-2 pb-1 hover:text-brand-600 dark:hover:text-brand-400 cursor-pointer transition-colors">
Produit & Ref <span>▼</span></div>
<div
class="px-2 pb-1 border-l border-slate-200 dark:border-slate-700 hover:text-brand-600 dark:hover:text-brand-400 cursor-pointer transition-colors">
Famille <span>▼</span></div>
<div class="px-2">
<div
class="text-center border-b border-slate-200 dark:border-slate-700 pb-1 mb-1 text-[9px] text-slate-400 font-bold uppercase tracking-[0.2em]">
Quantités Mensuelles</div>
<div class="grid grid-cols-12 gap-1 text-center text-[10px] leading-tight">
<div title="Mars 2024">Mar<br>24</div>
<div title="Avril 2024">Avr<br>24</div>
<div title="Mai 2024">Mai<br>24</div>
<div title="Juin 2024">Jui<br>24</div>
<div title="Juillet 2024">Jul<br>24</div>
<div title="Août 2024">Aoû<br>24</div>
<div title="Septembre 2024">Sep<br>24</div>
<div title="Octobre 2024">Oct<br>24</div>
<div title="Novembre 2024">Nov<br>24</div>
<div title="Décembre 2024">Déc<br>24</div>
<div title="Janvier 2025">Jan<br>25</div>
<div class="text-brand-600 dark:text-brand-400 font-black">Fév<br>25</div>
</div>
</div>
<div
class="text-center px-1 pb-1 border-l border-slate-200 dark:border-slate-700 hover:text-brand-600 dark:hover:text-brand-400 cursor-pointer transition-colors">
Tot. 12m<span>▼</span></div>
<div
class="text-center px-1 pb-1 hover:text-brand-600 dark:hover:text-brand-400 cursor-pointer transition-colors">
CA (€)<span>▼</span></div>
<div
class="text-center px-2 pb-1 bg-slate-100 dark:bg-slate-800/80 rounded-t pt-2 border-x border-t border-slate-200 dark:border-slate-700 hover:text-brand-600 dark:hover:text-brand-400 cursor-pointer transition-colors">
Marge (€/%)<span>▼</span></div>
<div class="text-center px-1 pb-1 border-l border-slate-200 dark:border-slate-700">Init.</div>
<div
class="text-center px-1 pb-1 hover:text-brand-600 dark:hover:text-brand-400 cursor-pointer transition-colors">
Score<span>▼</span></div>
<div class="px-2 pb-1">Analyse IA</div>
<div
class="text-center px-2 pb-1 text-brand-700 dark:text-brand-400 font-bold border-l border-slate-200 dark:border-slate-700">
Action Arbitrage</div>
</div>
<div class="flex-1 overflow-y-auto w-full">
<!-- Row 1 -->
<div
class="data-grid border-b border-slate-100 dark:border-slate-800/50 p-2 hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors">
<div class="px-2 min-w-0">
<div class="font-bold text-sm truncate" title="T-Shirt Essential Basic">T-Shirt Essential Basic
</div>
<div class="text-[11px] text-slate-500 font-mono-nums mt-0.5 tracking-tight">REF-892345</div>
</div>
<div class="px-2 min-w-0">
<span
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-800 text-[10px] px-2 py-1 rounded truncate block text-center uppercase font-bold shadow-sm">T-Shirts
H</span>
</div>
<div class="grid grid-cols-12 gap-1 px-2 font-mono-nums">
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 dark:text-indigo-200 text-xs text-center py-1.5 rounded-sm">
41</div>
<div
class="bg-indigo-100 dark:bg-indigo-900/50 text-indigo-900 dark:text-indigo-200 text-xs text-center py-1.5 rounded-sm font-bold">
95</div>
<div class="bg-indigo-600 text-white text-xs text-center py-1.5 rounded-sm font-bold shadow-sm">
120</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 dark:text-indigo-200 text-xs text-center py-1.5 rounded-sm">
64</div>
<div
class="bg-slate-50 dark:bg-slate-800/40 text-slate-500 dark:text-slate-400 text-xs text-center py-1.5 rounded-sm">
22</div>
<div
class="bg-slate-50 dark:bg-slate-800/40 text-slate-500 dark:text-slate-400 text-xs text-center py-1.5 rounded-sm">
18</div>
<div
class="bg-slate-50 dark:bg-slate-800/40 text-slate-500 dark:text-slate-400 text-xs text-center py-1.5 rounded-sm">
15</div>
<div
class="bg-slate-50 dark:bg-slate-800/40 text-slate-500 dark:text-slate-400 text-xs text-center py-1.5 rounded-sm">
12</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 dark:text-indigo-200 text-xs text-center py-1.5 rounded-sm">
45</div>
<div
class="bg-indigo-100 dark:bg-indigo-900/50 text-indigo-900 dark:text-indigo-200 text-xs text-center py-1.5 rounded-sm font-bold">
88</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 dark:text-indigo-200 text-xs text-center py-1.5 rounded-sm">
54</div>
<div
class="bg-slate-50 dark:bg-slate-800/40 text-slate-600 dark:text-slate-300 text-xs text-center py-1.5 rounded-sm font-black border-2 border-brand-500/30">
30</div>
</div>
<div
class="text-center px-1 font-mono-nums text-sm font-black text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/30 py-1.5 rounded-md border border-indigo-100 dark:border-indigo-800">
604</div>
<div class="text-center px-1 font-mono-nums text-sm font-bold tracking-tight">12 450</div>
<div
class="text-center px-2 flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-800/30 rounded-md py-1 border border-slate-100 dark:border-slate-800 shadow-inner">
<span class="font-mono-nums text-xs font-black text-emerald-700 dark:text-emerald-400">5 602
€</span>
<span
class="font-mono-nums text-[11px] text-white bg-emerald-600 px-2 mt-1 rounded-full font-bold">45.0%</span>
</div>
<div class="text-center px-1 font-black text-xs text-slate-400">A</div>
<div class="text-center px-1">
<span
class="inline-flex items-center justify-center px-2 py-1.5 rounded-lg text-xs font-black bg-emerald-100 text-emerald-800 dark:bg-emerald-900/60 dark:text-emerald-300 border-2 border-emerald-200 dark:border-emerald-700 w-full shadow-sm">92</span>
</div>
<div class="px-2 text-xs text-slate-600 dark:text-slate-300 leading-snug font-medium italic">
Saisonnier été (M2-M4). Très bonne rentabilité absolue. Excellente récurrence. Maintien Fort
préconisé par l'algorithme.</div>
<div class="text-center px-2 min-w-0">
<select
class="w-full text-xs font-black border-2 border-brand-500 dark:border-brand-400 bg-white dark:bg-slate-800 rounded-lg py-2 px-2 focus:ring-4 focus:ring-brand-500/20 outline-none cursor-pointer text-brand-900 dark:text-brand-200 shadow-md">
<option selected>A - Cœur</option>
<option>B - Standard</option>
<option>C - Optionnel</option>
<option>Z - Sortie</option>
</select>
</div>
</div>
<!-- Row 2 (Alerte) -->
<div
class="data-grid border-b border-rose-100 dark:border-rose-900/30 bg-rose-50/50 dark:bg-rose-950/20 p-2 hover:bg-rose-50 dark:hover:bg-rose-900/40 transition-colors relative">
<div class="absolute left-0 top-0 bottom-0 w-1.5 bg-amber-500"></div>
<div class="px-2 pl-4 min-w-0">
<div class="font-bold text-sm truncate" title="Polo Piqué Summer Ed.">Polo Piqué Summer Ed.
</div>
<div class="text-[11px] text-slate-500 font-mono-nums mt-0.5 tracking-tight">REF-744111</div>
</div>
<div class="px-2 min-w-0">
<span
class="bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border border-orange-100 dark:border-orange-800 text-[10px] px-2 py-1 rounded truncate block text-center uppercase font-bold shadow-sm">Polos
H</span>
</div>
<div class="grid grid-cols-12 gap-1 px-2 font-mono-nums">
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 text-xs text-center py-1.5 rounded-sm">
32</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 text-xs text-center py-1.5 rounded-sm">
45</div>
<div
class="bg-indigo-100 dark:bg-indigo-900/50 text-indigo-900 text-xs text-center py-1.5 rounded-sm font-bold">
78</div>
<div class="bg-indigo-500 text-white text-xs text-center py-1.5 rounded-sm font-bold shadow-sm">
102</div>
<div
class="bg-indigo-100 dark:bg-indigo-900/50 text-indigo-900 text-xs text-center py-1.5 rounded-sm font-bold">
88</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 text-xs text-center py-1.5 rounded-sm">
54</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 text-xs text-center py-1.5 rounded-sm">
41</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 text-xs text-center py-1.5 rounded-sm">
33</div>
<div
class="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 text-xs text-center py-1.5 rounded-sm">
21</div>
<div
class="bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-400 font-black text-xs text-center py-1.5 rounded-sm shadow-inner">
0</div>
<div
class="bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-400 font-black text-xs text-center py-1.5 rounded-sm shadow-inner">
0</div>
<div
class="bg-amber-600 text-white font-black text-xs text-center py-1.5 rounded-sm border-2 border-amber-400 shadow-md transform scale-110 z-10">
0</div>
</div>
<div
class="text-center px-1 font-mono-nums text-sm font-black text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 py-1.5 rounded-md border border-amber-200 dark:border-amber-800">
494</div>
<div class="text-center px-1 font-mono-nums text-sm font-bold tracking-tight">8 215</div>
<div
class="text-center px-2 flex flex-col items-center justify-center bg-rose-50 dark:bg-rose-900/20 rounded-md py-1 border border-rose-100 dark:border-rose-800 shadow-inner">
<span class="font-mono-nums text-xs font-black text-rose-700 dark:text-rose-400">1 478 €</span>
<span
class="font-mono-nums text-[11px] text-white bg-rose-600 px-2 mt-1 rounded-full font-bold">18.0%</span>
</div>
<div class="text-center px-1 font-black text-xs text-slate-500">B</div>
<div class="text-center px-1">
<span
class="inline-flex items-center justify-center px-2 py-1.5 rounded-lg text-xs font-black bg-amber-100 text-amber-800 dark:bg-amber-900/60 dark:text-amber-300 border-2 border-amber-200 dark:border-amber-700 w-full shadow-sm">65</span>
</div>
<div class="px-2 text-xs text-amber-800 dark:text-amber-300 font-bold leading-snug">⚠️ ALERTE :
Arrêt brutal des ventes (M11, M12, Now). Carence fournisseur ou obsolescence ? Marge
insuffisante.</div>
<div class="text-center px-2 min-w-0">
<select
class="w-full text-xs font-black border-2 border-rose-600 dark:border-rose-500 bg-rose-50 dark:bg-rose-900/50 text-rose-900 dark:text-rose-100 rounded-lg py-2 px-2 focus:ring-4 focus:ring-rose-500/20 outline-none cursor-pointer shadow-md">
<option>A - Cœur</option>
<option>B - Standard</option>
<option>C - Optionnel</option>
<option selected>Z - Sortie</option>
</select>
</div>
</div>
</div>
<!-- Summary Bar -->
<div
class="bg-slate-900 dark:bg-black text-white p-4 flex justify-between items-center shadow-2xl border-t border-slate-800 shrink-0">
<div
class="text-xs font-bold text-slate-400 bg-slate-800 px-4 py-2 rounded-full border border-slate-700">
Périmètre : <span class="text-white">NIKE EUROPE BV (Vue Globale)</span> • <span
class="text-brand-400">1 240 Réf.</span>
</div>
<div class="flex space-x-12 items-center">
<div class="flex flex-col items-center">
<span class="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">Volume Total
Vendu</span>
<span class="font-mono-nums font-black text-xl text-white">12 450</span>
</div>
<div class="flex flex-col items-center">
<span class="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">CA Global
Généré</span>
<span class="font-mono-nums font-black text-xl text-emerald-400">345 200 €</span>
</div>
<div class="flex flex-col items-center">
<span class="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">Taux de
Marge Moyen</span>
<span class="font-mono-nums font-black text-xl text-emerald-400">41.2%</span>
</div>
</div>
<div class="flex space-x-3">
<button
class="bg-slate-700 hover:bg-slate-600 text-white px-6 py-3 rounded-xl text-sm font-bold transition-all active:scale-95 border border-slate-600">
Export Excel
</button>
<button
class="bg-brand-600 hover:bg-brand-500 text-white px-8 py-3 rounded-xl text-sm font-black transition-all shadow-lg active:scale-95">
Valider ce Fournisseur
</button>
</div>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,428 @@
---
stepsCompleted: [step-01-discovery, step-02-strategy, step-03-core-experience, step-04-emotional-response, step-05-inspiration-analysis, step-06-design-system, step-07-defining-experience, step-08-visual-foundation, step-09-design-directions, step-10-user-journeys, step-11-component-strategy, step-12-ux-patterns, step-13-responsive-accessibility, step-14-complete]
inputDocuments: [
'c:\Users\Michael\Git\CollectFlow\_bmad-output\planning-artifacts\prd.md',
'c:\Users\Michael\Git\CollectFlow\docs\database-schema.sql'
]
---
# UX Design Specification CollectFlow
**Author:** Michael
**Date:** 2026-02-20
---
<!-- UX design content will be appended sequentially through collaborative workflow steps -->
## Executive Summary
### Project Vision
L'interface de CollectFlow doit incarner "l'Ergonomie Décisionnelle". Ce n'est pas un outil d'exploration de données complexe, mais une plateforme d'action tactique. L'UX doit masquer la complexité des calculs (jusqu'à 10 000 lignes de données croisées : Ventes, CA, Marge) derrière une interface tabulaire évidente permettant de réviser et valider des assortiments (Gammes A, B, C, Z) en quelques clics.
### Target Users
**L'Acheteur B2B (ou Responsable Réseau) :**
- **Mentalité :** Axé sur le business, le temps est une ressource critique. Privilégie la donnée quantitative pure à l'interprétation graphique abstraite.
- **Fréquence d'usage :** Épisodique (2 à 3 fois par an pour une révision de collection). C'est le point central de notre philosophie UX.
- **Besoins :** Ne veut pas réapprendre l'outil à chaque saison ("Zero-Learning UX"). D'un coup d'œil, il doit pouvoir trier, filtrer et comparer les vrais chiffres pour appliquer son instinct métier (ex: modifier une gamme pour garder un produit de "halo").
### Key Design Challenges
- **Le "Cold Start" (Zero-Learning) :** L'utilisateur doit comprendre intuitivement comment filtrer son périmètre et agir sur la grille dès la première seconde sans documentation.
- **Densité vs Lisibilité (L'enjeu Tabulaire) :** Afficher un tableau de 10 000 lignes avec des historiques de 6 à 12 mois constitués de données chiffrées pures sans provoquer de fatigue visuelle.
- **Le Split-Screen Décisionnel :** Rendre la comparaison analytique entre la *"Gamme Actuelle"* et la *"Gamme Proposée par l'IA"* parfaitement évidente ligne par ligne.
### Design Opportunities
- **Interaction "Inline" Fluide :** Utiliser des menus déroulants directement dans les lignes du tableau pour les modifications de gammes, éliminant les pop-ups ou les pages de détail.
- **Tableau Analytique Haute Performance :** Un tableau robuste présentant les chiffres bruts des ventes mensuelles (Qté), clôturé par des colonnes de Totaux (Total Qté, Total CA, Total Marge sur la période) pour une lecture directe de l'impact financier.
- **Sorting & Filtering Omniprésent :** Chaque colonne cruciale (Nomenclatures, Gammes actuelles/proposées, Totaux Quantités, Marge, CA) doit être interactive, permettant des tris ascendants/descendants et des filtrages instantanés pour isoler immédiatement les top/flops performers.
- **Totalisation Dynamique (Floating Summary Bar) :** Une ligne de totaux (CA, Qté, Marge globaux du périmètre filtré) fixée à l'écran qui se met à jour instantanément lors des modifications de gammes, validant immédiatement l'impact d'une décision.
- **Scoring Colorimétrique Subtil :** Conserver l'affichage en clair des statistiques pour le jugement métier, tout en appliquant un léger fonds de couleur (Heatmap) sur la cellule de marge/CA pour guider l'œil sans masquer la donnée chiffrée.
- **Bulk Actions (Actions en masse) Invisibles mais Accessibles :** Intégrer des fonctions de sélection multiple qui n'encombrent pas l'interface par défaut, mais qui "apparaissent" (Progressive Disclosure) dès qu'un besoin d'action à grande échelle est nécessaire.
## Core User Experience
### Defining Experience
L'action fondamentale de CollectFlow est **la décision d'assortiment ligne à ligne, guidée par un Double Filtre (Algorithme vs IA)**. L'utilisateur scrolle à travers son catalogue et dispose de deux colonnes clés juxtaposées : le **Pre-Score Algorithmique** (la réalité des ventes) et **l'Analyse IA** (la recommandation contextuelle). Si l'IA et l'Algorithme divergent, l'Acheteur arbitre instantanément et corrige la gamme en un clic via un menu déroulant "Inline", sans perdre le contexte.
### Platform Strategy
- **Plateforme Unique :** Application Web (Single Page Application).
- **Cible Matérielle :** Exclusivement Desktop/Laptop (écrans larges 1080p minimum) car la juxtaposition des colonnes de scoring et de texte d'analyse IA nécessite une grande largeur d'affichage. L'usage mobile est hors-périmètre.
- **Interaction :** Essentiellement Souris (scroll, clic sur menus déroulants, tri des en-têtes de colonnes) avec support Clavier pour accélérer. Chrome est le navigateur cible.
### Effortless Interactions
Ce qui doit paraître magique et sans effort :
- **La Comparaison Visuelle "Algo vs IA" :** L'affichage côte à côte d'une colonne "Score Algo" (calculée immédiatement sur le CA, Marge, Qté, Poids et Constance) et d'une colonne "Analyse IA". L'œil détecte immédiatement les anomalies ou les confirmations sans devoir ouvrir un panneau détaillé.
- **L'Édition "Inline" :** Changer un produit de la "Gamme B" à la "Gamme A" doit se faire directement dans la cellule du tableau, sans rechargement de page.
- **Le Recalcul Instantané :** Dès l'instant où une gamme est modifiée, la barre de "Totaux Flottante" (CA, Quantités, Marge) se met à jour en moins de 100ms.
### Critical Success Moments
- **L'Arbitrage Éclair :** Quand l'utilisateur voit qu'un produit considéré comme moyen par l'algorithme est sauvé par une analyse IA pointue (expliquant par exemple une rupture de stock passagère), lui permettant de prendre une décision éclairée en 3 secondes.
- **Le Tri Rapide des "Conflits" :** L'Acheteur peut trier son tableau pour ne voir que les lignes où le Score Algorithmique est "Bon" mais où l'IA propose de "Retirer" le produit, ciblant immédiatement son temps de cerveau disponible sur les vraies questions complexes.
- **La Validation Sans Peur :** La prévisualisation des "Deltas" récapitulant l'impact final avant la sauvegarde.
### Experience Principles
1. **La Machine et l'IA comme Conseillers, l'Acheteur comme Décideur :** L'interface présente les faits (Score Mathématique) et les opinions (Analyse Textuelle IA), mais laisse le contrôle total à l'utilisateur via une interface tabulaire.
2. **Latence Zéro :** Tout filtrage, tri ou modification en session doit être instantané. Le réseau n'est sollicité que pour requêter l'IA ou sauvegarder.
3. **Pardonner l'Erreur :** Les modifications sont conservées en local ("Draft state") jusqu'à la sauvegarde finale globale.
## Desired Emotional Response
### Primary Emotional Goals
**Maitrise et Confiance.**
L'Acheteur B2B doit se sentir en contrôle absolu. Il ne subit pas l'outil ni l'Intelligence Artificielle ; il les utilise comme des leviers pour amplifier son propre jugement métier. Le système lui donne le sentiment d'être plus rapide, d'avoir un "filet de sécurité" naturel, et une vision "Rayons X" sur sa collection.
### Emotional Journey Mapping
- **À l'ouverture (Cold Start) :** Sentiment de clarté ("Ouf, ce n'est pas une usine à gaz"). Pas d'anxiété face à l'apprentissage ; l'interface tabulaire est familière.
- **Pendant l'utilisation (The Flow) :** Sentiment d'efficacité redoutable. Le tri instantané des 10 000 lignes et l'édition "Inline" créent un rythme de travail hypnotique et fluide.
- **Lors du Nettoyage Algo ("Design de Révélation") :** Soulagement immédiat. En voyant l'interface épurer automatiquement les produits sans ventes sur 12 mois (Auto-Z) et lever des alertes claires sur les ruptures potentielles (0 vente depuis 2-3 mois), l'Acheteur se sent protégé de l'erreur d'inattention ("Le système veille sur moi").
- **Lors de l'Arbitrage Algo vs IA :** Sentiment intellectuellement stimulant ("Je suis l'expert qui tranche les anomalies complexes").
- **À la clôture session :** Sentiment de sécurité ("Mon travail est propre et je connais mon impact direct").
### Micro-Emotions
- **Confiance > Anxiété :** Le design tabulaire strict rassure l'esprit cartésien de l'Acheteur.
- **Accomplissement > Frustration :** La barre de totaux qui se met à jour instantanément offre un shoot de dopamine (accomplissement micro-tactique) à chaque validation de gamme.
- **Surprise positive :** La découverte silencieuse de fuites de CA grâce aux alertes visuelles ciblées ("Détective").
### Design Implications
- **Design pour la Confiance :** Les couleurs ("Heatmap") doivent rester subtiles. Les textes des analyses IA doivent être consultatifs (jamais prescriptifs).
- **Design de Révélation :** Utilisation de badges forts pour les épurations évidentes (ex: tag 'Z - Épuration') et d'icônes d'alerte (⚠️ orange) pour les ruptures silencieuses, réduisant radicalement la charge mentale.
- **Design pour l'Accomplissement :** Le feedback visuel lors du changement d'une gamme "Inline" doit être franc et satisfaisant (micro-animation au changement de valeur). L'écran final des "Deltas" doit raconter l'impact financier de la session (CA sauvé, Stock dormant préservé).
### Emotional Design Principles
3. **Le Conseiller, pas le Remplaçant :** L'IA adopte un ton de copilotage objectif, laissant systématiquement la place finale au libre arbitre de l'utilisateur.
## UX Pattern Analysis & Inspiration
### Inspiring Products Analysis
**Apple UI (iOS / macOS / Numbers)**
- **Ce qu'ils font bien :** Le Minimalisme Narratif. Apple ne montre que les contrôles nécessaires à l'instant T (Progressive Disclosure). Les interfaces respirent, même quand il y a beaucoup d'informations.
- **Ce qui rend l'expérience fluide :** La cohérence absolue des composants, les typographies ultra-lisibles (San Francisco), et le feedback visuel immédiat à chaque interaction microscopique.
### Transferable UX Patterns
**1. Design Système Typographique Obessionnel :**
- Utiliser une hiérarchie de textes extrêmement rigoureuse. Les chiffres financiers (CA, Marge) utilisent une police monospace (chiffres alignés verticalement) pour une scannabilité instantanée.
**2. Le "Progressive Disclosure" (Divulgation Progressive) :**
- Masquer les filtres complexes derrière un bouton "Filtres" épuré. Les actions en masse (Bulk actions) n'apparaissent que lorsqu'une ligne ou toutes les lignes sont sélectionnées.
**3. Le Feedback Visuel (Micro-interactions) :**
- Bien que sur le Web, reproduire la sensation de réactivité d'Apple. Lorsqu'une "Gamme" passe de B à A, la ligne opère une transition douce (fade-in coloré) pour valider l'action à l'œil.
### Anti-Patterns to Avoid
- **L'Esthétisme au détriment de la Densité :** L'Acheteur a besoin de voir un maximum de lignes sur son grand écran. Il faut l'épure d'Apple, mais la densité d'un outil pro (pas d'espaces blancs démesurés).
- **Les Icônes Cryptiques sans labels :** Toujours privilégier Textes clairs + Icônes standards ("Filtrer", "Trier", "Générer IA") pour le "Zero-Learning".
### Design Inspiration Strategy
**What to Adopt (Ce qu'on prend) :**
- La clarté typographique (police type Inter / San Francisco) pour les données.
- La disparition des bordures de tableaux lourdes au profit de séparations gris très clair.
- Les feedbacks de validation subtils et immédiats.
**What to Adapt (Ce qu'on adapte) :**
- La densité d'information. CollectFlow compactera l'UI Apple pour afficher le plus de lignes possibles simultanément sans scroller excessivement.
**What to Avoid (Ce qu'on rejette) :**
- Les animations gadgets ou très longues (>200ms) qui ralentiraient le "Flow" frénétique d'un acheteur validant 500 produits.
## Design System Foundation
### 1.1 Design System Choice
**Headless UI + Utility-First CSS**
Nous opterons pour une architecture front-end composable basée sur **React**, **Tailwind CSS**, et des bibliothèques de composants non-stylés ("Headless") comme **Shadcn UI** ou **Radix UI**, couplées à un moteur de tableau haute performance (**TanStack Table** avec virtualisation).
### Rationale for Selection
- **Vitesse & Indépendance Visuelle :** Tailwind CSS permet de coder rapidement une interface au pixel près, reproduisant l'élégance "façon Apple" (San Francisco/Inter, gris subtils, absence de bordures lourdes), sans subir l'esthétique générique "Google Material" d'un système comme MUI.
- **Densité Contrôlée :** Dans un workflow B2B où chaque pixel compte, une approche "Headless" nous donne un contrôle absolu de la marge et du padding, essentiel pour densifier notre tableau de 10 000 lignes tout en conservant une grande lisibilité.
- **Performance Tabulaire Critique :** L'interface repose presque exclusivement sur les manipulations de très larges volumes de données côté client. TanStack Table garantit des tris, filtres et rendus sans latence ("Zero-Latency Flow").
### Implementation Approach
- Installation d'une base React/Vite.
- Configuration stricte du thème Tailwind pour adhérer aux contraintes typographiques et colorimétriques définies (Heatmap légère pour les scores, pas de composants intempestifs).
- Mise en place d'un composant Tableau virtuel (React-Window ou équivalent) dès la fondation pour absorber la charge de données du schéma SQL sans pénaliser le navigateur.
### Customization Strategy
- **Typographie :** Utiliser des polices à haute lisibilité pour les données et une police Monospace stricte pour les données financières afin de garantir l'alignement vertical des décimales.
- **Micro-Interactions :** Standardiser une durée de transition de 100ms maximum pour les feedbacks visuels (clics, changements de gammes 'Inline') pour renforcer l'aspect dynamique et l'impact immédiat des décisions, sans jamais ralentir l'Acheteur.
## 2. Core User Experience
### 2.1 Defining Experience
**L'Arbitrage Augmenté ("The Augmented Trade-off")**
L'action fondatrice de CollectFlow est le moment où l'Acheteur regarde une ligne de produit, constate visuellement une divergence entre son intuition métier, le "Pre-Score Algorithmique" (mathématique), et la "Recommandation IA" (contextuelle). Il tranche ce désaccord en modifiant la gamme (ex: Z -> B) via un menu déroulant "Inline", et valide instantanément l'impact de son choix grâce à la mise à jour en temps réel de la barre de Totaux.
### 2.2 User Mental Model
- **Modèle Actuel :** L'Acheteur a l'habitude d'extraire des données brutes vers Excel, d'utiliser des recherches en V croisées (VLOOKUP) avec son budget, de colorier des lignes à la main et de saisir de nouvelles valeurs sans vision globale immédiate de son impact financier total.
- **Le Nouveau Paradigme :** Un tableau de bord intelligent qui a déjà mâché le travail d'extraction et de calcul (le "Pre-Score" et l'"Auto-Z" réduisent 80% du bruit). L'attente centrale est une fluidité absolue : l'Acheteur ne "navigue" pas entre des pages de détails de produits, il "pilote" une liste géante.
### 2.3 Success Criteria
1. **Confiance Visuelle (Zero ambiguity) :** Au survol (Hover), la ligne entière doit être surlignée distinctement pour éviter toute erreur de saisie sur un tableau dense de 10 000 lignes.
2. **Vitesse Pure (Action "Headless") :** L'ouverture du menu déroulant et la sélection d'une nouvelle gamme (A, B, C, Z) doivent s'effectuer sans le moindre lag de réseau.
3. **Récompense Immédiate (The Dosage of Dopamine) :** L'impact de la décision est validé par l'animation instantanée (≤ 100ms) de la barre de "Totaux Flottante" (CA, Quantités, Marge), prouvant à l'Acheteur qu'il vient de créer de la valeur.
### 2.4 Novel UX Patterns
Le format de base (Dashboard Tabulaire) est délibérément **très classique** pour minimiser la courbe d'apprentissage.
La "Nouveauté" réside dans :
- **La Juxtaposition Cognitive :** Mettre côte-à-côte, sur la même ligne, le calcul purement arithmétique (le Score) et l'inférence contextuelle textuelle (l'IA), forçant l'humain à jouer le rôle d'arbitre final.
- **La Bar de Totaux Hautement Réactive :** Un composant qui donne l'illusion de reculyler 10 000 lignes croisées à la volée dans un navigateur web, offrant une expérience "Logiciel de Bureau" (façon Apple Numbers) plutôt que "Page Web Classique".
### 2.5 Experience Mechanics
1. **Initiation :** Pendant le défilement (Scroll) de sa liste filtrée, l'œil de l'Acheteur est attiré par une alerte visuelle (tag ⚠️ orange de rupture de stock) ou un contraste entre un Score élevé et une recommandation IA "Retirer".
2. **Interaction :** L'utilisateur clique sur la cellule `Gamme` de la ligne concernée. Le menu déroulant s'ouvre, il choisit sa nouvelle sélection.
3. **Feedback :** La cellule affiche la nouvelle valeur soutenue par une légère transition de fond coloré (ex: vert clair pour `A`, rouge léger pour `Z`). De manière synchrone, les chiffres de la "Barre de Résumé Flottante" pulsent ou défilent vers leurs nouvelles valeurs.
4. **Completion :** L'action est terminée, enregistrée en local (Draft state). L'Acheteur passe avec fluidité à la ligne critique suivante, sans aucune action de confirmation requise pour cette modification unitaire.
## Visual Design Foundation
### Color System (Dual Theme)
Le système colorimétrique sera 100% sémantique et binaire (Light/Dark).
**1. Les Neutres (L'interface silencieuse) :**
- **Mode Clair :** Fond `White`, bordures `Gray-200`, texte principal `Slate-900`.
- **Mode Sombre :** Fond `Slate-950` (un gris-bleu très profond, pas de noir pur `#000` qui fatigue la rétine), bordures `Slate-800`, texte `Slate-200`.
**2. Les Sémantiques (La "Heatmap") :**
- Les couleurs ne servent *qu'à porter une information* (Jamais de décoration).
- **Succès / A / Bon Score Algo :** Vert émeraude désaturé en mode clair (`Emerald-50` fond / `Emerald-700` texte), et `Emerald-950` fond / `Emerald-400` texte en Mode Sombre.
- **Alerte / Z / Rupture :** Rouge-Orange (`Rose-50` / `Rose-900`).
- Ces couleurs sont appliquées en "Heatmap" (fond de cellule très léger) limitées aux colonnes Pre-Score et IA.
### Typography System
- **Données textuelles & UI :** `Inter` ou `San Francisco` (system-ui). Une typographie sans-serif, neutre, extrêmement lisible même en corps 12px.
- **Données financières (CA, Marge, Quantités) :** Utilisation stricte de la propriété CSS `font-variant-numeric: tabular-nums` (ou déclinaison Monospace type `JetBrains Mono`). C'est **critique** : 1000.00 et 999.00 doivent occuper exactement la même largeur en pixels pour une scannabilité verticale parfaite.
### Spacing & Layout Foundation
- **Densité Maximale ("Compact-First") :** Le padding sera réduit au strict minimum (ex: `32px` de hauteur par ligne) pour afficher un maximum de références (jusqu'à 50 lignes simultanément) sur un écran 1080p.
- **Grid de Lecture :** Retrait des bordures verticales dans le tableau. Seules de fines bordures horizontales (très claires) guideront l'œil, dans la plus pure tradition des grands logiciels d'analyse.
### Accessibility Considerations
- Le contraste entre le texte des données et le fond (même coloré par la Heatmap) devra toujours respecter la norme WCAG AA.
- Support natif du basculement instantané (Toggle) entre Mode Clair et Sombre, sans rechargement.
- Utilisation des touches fléchées du clavier (Haut/Bas) pour naviguer de ligne en ligne afin de limiter l'usage de la souris lors des longues sessions partielles.
## Design Direction Decision
### Design Directions Explored
Multiple directions were explored through an interactive HTML showcase, ranging from a "Split-Screen Analyst" approach (AI in a panel) to a "Dense Spreadsheet" and an "Extended Focus" layout. The exploration focused on balancing high data density (M1-M12 sales history) with professional tool ergonomics for 32" monitors.
### Chosen Direction
**Direction 1: The Extended Focus (Ultra-Dense)**
- **Fluid Layout**: Adapts to 100% width of large monitors (32").
- **Integrated Columns**: Nomenclature, M1-M12 history, Total 12m Quantity, and double-line Margin (€/%) are all visible in a single row.
- **Visual Hierarchy**: Centered alignment for financial data (CA, Margin) to provide breathing room between data blocks.
- **Detailed Historial View**: Explicit labels for Month/Year (e.g., "Jan 25") on the sliding 12-month window.
- **Production-Ready Formatting**: Full numerical values instead of shortened 'k' abbreviations for absolute precision in arbitrage.
### Design Rationale
This direction was chosen because it allows the Buyer to perform line-by-line arbitrage without horizontal scrolling or context switching. On a 32-inch screen, the fluid layout utilizes the full resolution to present a "panoramic" view of each product's performance. The inclusion of Nomenclature inline is critical for the initial global screening phase of the assortment process.
### Implementation Approach
The interface will be built using a robust Grid System (Tailwind CSS / Radix UI) to ensure responsive fluidity. Typography will prioritize efficiency with 'Inter' for UI and 'JetBrains Mono' for tabular data. Categorization actions (A, B, C, Z) will be handled via high-contrast select menus to ensure rapid tactile confirmation.
## User Journey Flows
### 1. Épuration & Filtrage Massif
Ce parcours permet à l'acheteur de rapidement éliminer les références sans vente ou les doublons pour réduire la charge cognitive avant l'analyse fine.
```mermaid
graph TD
A[Sélection Fournisseur] --> B[Chargement de la Vue Globale]
B --> C{Besoin de focus ?}
C -- Oui --> D[Filtrage par Nomenclature / Famille]
C -- Non --> E[Tri par Quantités ASC]
D --> E
E --> F[Identification des 'Zéros' de vente]
F --> G[Sélection Multiple]
G --> H[Action de Masse : Sortie Z]
H --> I[Mise à jour du Résumé Financier]
```
### 2. Arbitrage Fin (Ligne à Ligne)
C'est le cœur de l'application. L'acheteur utilise les indices visuels (Heatmap) et l'IA pour statuer sur chaque produit.
```mermaid
graph TD
A[Focus sur une ligne] --> B[Analyse de la Heatmap 12m]
B --> C[Lecture du Score & Commentaire IA]
C --> D{Alignement avec l'IA ?}
D -- Oui --> E[Confirmation du Choix Suggéré]
D -- Non --> F[Modification manuelle du Statut A/B/C/Z]
E --> G[Passage auto à la ligne suivante]
F --> G
G --> H[Mise à jour temps réel des totaux CA/Marge]
```
### 3. Validation & Bouclage
L'acheteur vérifie l'impact macro-économique de ses choix individuels avant de sceller l'assortiment pour le fournisseur.
```mermaid
graph TD
A[Fin de la revue des articles] --> B[Consultation de la Barre de Résumé]
B --> C{CA & Marge satisfaisants ?}
C -- Non --> D[Ajustement des arbitrages critiques]
D --> B
C -- Oui --> E[Validation Finale du Fournisseur]
E --> F[Génération de l'Assortiment Draft]
F --> G[Passage au Fournisseur Suivant]
```
### Journey Patterns
- **Pattern de Confirmation Implicite** : Le passage automatique à la ligne suivante après un arbitrage accélère considérablement le flux de travail.
- **Pattern de Feedback Financier** : Mise à jour instantanée des indicateurs globaux (CA/Marge) dans la barre flottante à chaque modification de statut (A/B/C/Z).
- **Pattern de Filtrage Progressif** : Utilisation de la Nomenclature pour passer d'une vue de survol à une vue spécialisée sans perdre le contexte financier global.
### Flow Optimization Principles
- **Minimisation du Scroll** : Utilisation de la grille fluide "Ultra-Dense" pour garder toutes les données décisionnelles dans le viewport.
- **Réduction de la Charge Cognitive** : Mise en évidence visuelle (Heatmap/Couleurs) des anomalies de vente pour attirer l'œil de l'acheteur là où son intervention est requise.
- **Feedback Immédiat** : Chaque interaction produit un résultat visible (changement de couleur de ligne, mise à jour des totaux).
## Component Strategy
### Design System Components
L'application s'appuiera sur **Tailwind CSS / Radix UI** pour les composants d'infrastructure.
- **Layout & Structure** : Sidebar, TopNav, Container Fluide.
- **Gestion des États** : Modal (Dialog), Dropdown Menu, Tooltip.
- **Formulaires** : Input text (Search), Button (Primary/Secondary), Checkbox (Multi-sélection).
### Custom Components
Ces blocs sont conçus sur mesure pour répondre aux besoins de densité et d'analyse métier de CollectFlow.
#### Heatmap Sales Grid
**Purpose:** Offrir une lecture instantanée de la saisonnalité et des ruptures de stock sur 12 mois glissants.
- **Anatomy**: Grille de 12 colonnes avec coloration conditionnelle (Bleu = Ventes, Orange = Alerte chute, Gris = Zéro).
- **Accessibility**: Tooltips détaillant les quantités exactes et le mois/année au survol de chaque cellule.
#### Financial Cell
**Purpose:** Présenter les indicateurs de performance financière (CA et Marge) de manière équilibrée.
- **Anatomy**: Cellule double ligne.
- **Variants**: Affichage du pourcentage de marge avec un badge de couleur (Vert > 40%, Orange 20-40%, Rouge < 20%).
#### AI Insights Block
**Purpose:** Synthétiser le raisonnement de l'algorithme pour justifier le score suggéré.
- **Interaction**: Texte court affiché directement, avec possibilité d'expansion pour les analyses complexes.
#### Arbitrage Select
**Purpose:** Permettre un changement de statut (A/B/C/Z) rapide et tactile.
- **States**: Bordure colorée spécifique à chaque statut (Cœur = Indigo, Sortie = Rose/Rouge).
#### Floating Summary Bar
**Purpose:** Garder l'acheteur informé de l'impact macro de ses micro-décisions.
- **Anatomy**: Barre fixe en bas d'écran calculant dynamiquement les totaux (Volume total, CA cumulé, Marge moyenne).
### Component Implementation Strategy
- Tous les composants custom seront stylisés via les **Tokens de Design** définis à l'étape 6.
- Utilisation de la police `JetBrains Mono` pour toutes les données tabulaires afin de garantir un alignement vertical parfait des chiffres.
- Support natif du **Mode Sombre** pour chaque composant.
### Implementation Roadmap
- **Phase 1 - Fondations** : Grille de données fluide et Layout global.
- **Phase 2 - Composants de Lecture** : Heatmap 12m, Cellules financières, Blocs IA.
- **Phase 3 - Composants d'Action** : Sélecteur d'arbitrage et Barre de résumé dynamique.
## UX Consistency Patterns
### Button Hierarchy
- **Action Primaire (Brand 600)** : Boutons "Valider le Fournisseur", "Confirmer l'Assortiment". Utilisés pour clore un cycle de décision. Un seul par vue principale.
- **Action Secondaire (Slate 700 / Outline)** : Boutons "Export Excel", "Détails Produit". Pour les actions non critiques.
- **Action de Danger (Rose 600)** : Boutons "Vider la sélection", "Réinitialiser". Utilisés avec une confirmation de sécurité.
### Feedback Patterns
- **Indicateurs de Performance (Marge)** :
- **Vert (Émeraude)** : Marge > 45%. Succès.
- **Orange (Amber)** : Marge entre 20% et 40%. Attention.
- **Rouge (Rose)** : Marge < 20%. Critique, nécessite une action Z ou renégociation.
- **Anomalies de Vente (Heatmap)** :
- Bordure Orange sur une cellule si la variation mensuelle est négative de plus de 50%.
- **Notifications IA** :
- Utilisation des icônes : 🤖 (Optimisation), ⚠️ (Risque), ✅ (Opportunité).
### Navigation Patterns
- **Auto-Jump Logic** : Lors d'un arbitrage manuel (A, B, C ou Z), l'application déplace automatiquement le focus visuel sur la référence suivante du tableau.
- **Sticky Headers** : Les colonnes d'en-tête et le résumé financier bas restent fixes pendant le défilement pour garantir que les totaux et les labels sont toujours visibles.
### Additional Patterns
- **Empty States** : En l'absence de données pour un fournisseur, affichage d'une vue illustrée incitant à vérifier la période de synchronisation ERP ou à changer de fournisseur.
- **Loading Pattern** : Utilisation de Skeletons (formes grises animées) respectant la structure de la grille "Ultra-Dense" pour éviter les sauts de mise en page pendant le chargement des données.
## Responsive Design & Accessibility
### Responsive Strategy
L'application adopte une approche **Desktop-First / Data-Centric**, privilégiant l'efficacité de l'analyse sur grand écran tout en maintenant une accessibilité critique sur les autres supports.
- **Desktop (32" et +)** : Affichage intégral de la grille fluide. Espace utilisé pour maximiser la visibilité des préconisations IA et des séries temporelles.
- **Laptop (13-15")** : Mode compact avec réduction des marges et masquage des colonnes de contexte non essentielles.
- **Tablette / Mobile** : Vue de consultation focalisée sur les totaux de CA/Marge et la capacité de valider un arbitrage simple déjà préparé.
### Breakpoint Strategy
- **Large Desktop (XL)** : > 1440px (Optimisé pour 32").
- **Desktop (LG)** : 1024px - 1439px.
- **Tablet (MD)** : 768px - 1023px.
- **Mobile (SM)** : < 767px.
### Accessibility Strategy (WCAG AA)
- **Contraste** : Ratio minimum de 4.5:1, même au sein de la Heatmap.
- **Typographie** : `font-variant-numeric: tabular-nums` pour un alignement décimal parfait des monnaies.
- **Focus States** : Indicateurs de focus visuels clairs lors de la navigation au clavier.
### Testing Strategy
- **Performance** : Test de réactivité de la grille jusqu'à 10 000 entrées.
- **Compatibilité** : Validation croisée sur les moteurs Blink (Chrome/Edge) et Webkit (Safari).
- **Accessibilité** : Audit automatisé (Axe/Lighthouse) complété par un test manuel de navigation au clavier.
### Implementation Guidelines
- Utilisation de `CSS Grid` et `Flexbox` pour la fluidité.
- Isolation des composants métier pour garantir que le moteur de calcul financier (Summary Bar) reste performant même en cas de scroll intensif.
- Respect strict des rôles ARIA pour le sélecteur d'arbitrage A/B/C/Z.
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+30
View File
@@ -0,0 +1,30 @@
import { db } from "./src/db";
import { ventesProduits } from "./src/db/schema";
import { sql } from "drizzle-orm";
async function diagnose() {
console.log("Checking DB connection...");
try {
const result = await db.execute(sql`SELECT 1`);
console.log("DB Connection OK.");
console.log("Checking table 'ventes_produits'...");
const tableCheck = await db.execute(sql`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'ventes_produits'
)`);
console.log("Table exists:", tableCheck.rows[0].exists);
if (tableCheck.rows[0].exists) {
const count = await db.select({ count: sql`count(*)` }).from(ventesProduits);
console.log("Total rows in 'ventes_produits':", count[0].count);
}
} catch (err) {
console.error("Diagnosis failed:", err);
} finally {
process.exit(0);
}
}
diagnose();
+137
View File
@@ -0,0 +1,137 @@
-- ================================================================
-- TABLE : ventes_produits
-- Import Excel Ventes Fournisseurs
-- Structure : 1 ligne = 1 produit × 1 magasin × 1 période
--
-- Stratégie : UPSERT sur (codein, magasin, periode)
-- → Chaque import met à jour les données existantes
-- → L'historique des anciens mois est conservé automatiquement
-- → On peut ré-importer le même fichier plusieurs fois sans doublons
-- ================================================================
DROP TABLE IF EXISTS ventes_produits;
CREATE TABLE ventes_produits (
id SERIAL PRIMARY KEY,
-- Identification produit
codein VARCHAR(20) NOT NULL, -- Code interne produit
code_fournisseur VARCHAR(20), -- Code fournisseur (ex: G009)
nom_fournisseur VARCHAR(255), -- Nom fournisseur (ex: GLAMA INTERNATIONAL)
libelle1 VARCHAR(500), -- Libellé produit
gtin VARCHAR(30), -- Code-barres GTIN/EAN
reference VARCHAR(100), -- Référence fournisseur
colisage NUMERIC(10,5), -- Colisage
-- Gamme & Nomenclature
code_gamme VARCHAR(20), -- CODE : gamme (A, Z, Aucune)
code3 VARCHAR(20), -- Code nomenclature (ex: 320211)
libelle3 VARCHAR(500), -- Libellé nomenclature
-- Dimension magasin & période
magasin VARCHAR(20) NOT NULL, -- HOUDEMONT, NANCY, TOTAL
code_magasin VARCHAR(10), -- 579, 292, ALL
annee SMALLINT, -- 2025, 2026
mois SMALLINT, -- 1..12
periode VARCHAR(10) NOT NULL, -- 202508, 202509, ... ou TOTAL
-- Métriques (valeurs négatives = ventes clients, sens comptable)
quantite NUMERIC(12,2),
montant_mvt NUMERIC(14,4),
marge_mvt NUMERIC(14,4),
-- Métadonnées d'import
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Contrainte UNIQUE pour l'UPSERT
CONSTRAINT uq_ventes_produit_magasin_periode
UNIQUE (codein, magasin, periode)
);
-- ================================================================
-- INDEX pour les performances Metabase
-- ================================================================
CREATE INDEX idx_ventes_codein ON ventes_produits(codein);
CREATE INDEX idx_ventes_fournisseur ON ventes_produits(code_fournisseur);
CREATE INDEX idx_ventes_gamme ON ventes_produits(code_gamme);
CREATE INDEX idx_ventes_code3 ON ventes_produits(code3);
CREATE INDEX idx_ventes_magasin ON ventes_produits(magasin);
CREATE INDEX idx_ventes_periode ON ventes_produits(periode);
CREATE INDEX idx_ventes_annee_mois ON ventes_produits(annee, mois);
CREATE INDEX idx_ventes_codein_magasin ON ventes_produits(codein, magasin, periode);
CREATE INDEX idx_ventes_updated_at ON ventes_produits(updated_at);
-- Trigger pour mettre à jour updated_at automatiquement à chaque UPSERT
CREATE OR REPLACE FUNCTION fn_update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_ventes_updated_at
BEFORE UPDATE ON ventes_produits
FOR EACH ROW
EXECUTE FUNCTION fn_update_updated_at();
-- ================================================================
-- VUES utiles pour Metabase
-- ================================================================
-- Vue synthèse par fournisseur & magasin
CREATE OR REPLACE VIEW v_ca_fournisseur AS
SELECT
code_fournisseur,
nom_fournisseur,
magasin,
annee,
mois,
periode,
SUM(ABS(quantite)) AS quantite_totale,
SUM(ABS(montant_mvt)) AS ca_total,
SUM(ABS(marge_mvt)) AS marge_totale,
CASE WHEN SUM(ABS(montant_mvt)) > 0
THEN ROUND(SUM(ABS(marge_mvt)) / SUM(ABS(montant_mvt)) * 100, 2)
ELSE 0 END AS taux_marge
FROM ventes_produits
WHERE magasin != 'TOTAL'
AND periode != 'TOTAL'
GROUP BY code_fournisseur, nom_fournisseur, magasin, annee, mois, periode;
-- Vue synthèse par nomenclature & magasin
CREATE OR REPLACE VIEW v_ca_nomenclature AS
SELECT
code3,
libelle3,
magasin,
annee,
mois,
periode,
SUM(ABS(quantite)) AS quantite_totale,
SUM(ABS(montant_mvt)) AS ca_total,
SUM(ABS(marge_mvt)) AS marge_totale,
CASE WHEN SUM(ABS(montant_mvt)) > 0
THEN ROUND(SUM(ABS(marge_mvt)) / SUM(ABS(montant_mvt)) * 100, 2)
ELSE 0 END AS taux_marge
FROM ventes_produits
WHERE magasin != 'TOTAL'
AND periode != 'TOTAL'
GROUP BY code3, libelle3, magasin, annee, mois, periode;
-- Vue synthèse par gamme & magasin
CREATE OR REPLACE VIEW v_ca_gamme AS
SELECT
code_gamme,
magasin,
annee,
mois,
periode,
SUM(ABS(quantite)) AS quantite_totale,
SUM(ABS(montant_mvt)) AS ca_total,
SUM(ABS(marge_mvt)) AS marge_totale
FROM ventes_produits
WHERE magasin != 'TOTAL'
AND periode != 'TOTAL'
GROUP BY code_gamme, magasin, annee, mois, periode;
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+14336
View File
File diff suppressed because it is too large. Load diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "collect-flow",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"ai": "^6.0.97",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"exceljs": "^4.4.0",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.9",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+14
View File
@@ -0,0 +1,14 @@
export default function ExportsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Exports</h1>
<p className="text-sm text-slate-400 mt-1">Génération des fichiers Excel différentiels pour l&apos;ERP.</p>
</div>
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
<p className="text-slate-500 text-sm">Sélectionnez un fournisseur dans la grille, puis cliquez sur <strong className="text-slate-400">«&nbsp;Exporter&nbsp;»</strong>.</p>
<p className="text-slate-600 text-xs mt-1">L&apos;export génère un fichier <code className="text-slate-500">.xlsx</code> avec le delta avant/après des gammes révisées.</p>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
import { Suspense } from "react";
interface GridPageProps {
searchParams: Promise<{ fournisseur?: string; magasin?: string }>;
}
export default async function GridPage({ searchParams }: GridPageProps) {
const params = await searchParams;
const codeFournisseur = params.fournisseur;
const magasin = params.magasin ?? "TOTAL";
// 1. Fetch available suppliers & stores
const [fournisseurs, magasins] = await Promise.all([
getFournisseurs(),
getMagasins()
]);
// 2. If no supplier selected, show compact selection UI
if (!codeFournisseur) {
return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
}
// 3. Load real product data for the selected supplier
const rows = await getGridData(codeFournisseur, magasin);
const selectedFournisseur = fournisseurs.find((f) => f.code === codeFournisseur);
return (
<GridClient
initialRows={rows}
codeFournisseur={codeFournisseur}
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
fournisseurs={fournisseurs}
magasins={magasins}
magasin={magasin}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Sidebar } from "@/components/shared/sidebar";
import { Header } from "@/components/shared/header";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: "var(--bg-base)" }}>
<Sidebar />
<main className="flex-1 ml-60 flex flex-col h-full overflow-hidden">
<Header />
<div className="flex-1 overflow-hidden">
{children}
</div>
</main>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { BarChart3, ArrowRight, TrendingUp, Award, Layers } from "lucide-react";
import Link from "next/link";
function InfoCard({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return (
<div
className="rounded-[14px] overflow-hidden"
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
>
<div className="px-5 py-4 flex items-center gap-2.5" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={2} />
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
</div>
<div className="px-5 py-5" style={{ background: "var(--bg-surface)" }}>
{children}
</div>
</div>
);
}
function FormulaStep({ step, title, formula }: { step: number; title: string; formula: string }) {
return (
<div className="flex items-start gap-3">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold flex-shrink-0 mt-0.5"
style={{ background: "var(--accent-bg)", color: "var(--accent)", border: "1px solid var(--accent-border)" }}
>
{step}
</div>
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>{title}</p>
<code className="text-[12px] mt-1 block px-3 py-2 rounded-[8px] font-mono" style={{ background: "var(--bg-elevated)", color: "var(--text-secondary)", border: "1px solid var(--border)" }}>
{formula}
</code>
</div>
</div>
);
}
function ScoreBadge({ value, label }: { value: string; label: string }) {
const num = parseFloat(value);
const color = num >= 80 ? "text-emerald-500" : num >= 50 ? "text-amber-500" : "text-rose-500";
const bg = num >= 80 ? "rgba(16, 185, 129, 0.1)" : num >= 50 ? "rgba(245, 158, 11, 0.1)" : "rgba(244, 63, 94, 0.1)";
const borderColor = num >= 80 ? "rgba(16, 185, 129, 0.2)" : num >= 50 ? "rgba(245, 158, 11, 0.2)" : "rgba(244, 63, 94, 0.2)";
return (
<div className="text-center">
<div
className={`text-[18px] font-black ${color} inline-flex items-center justify-center w-12 h-12 rounded-[10px]`}
style={{ background: bg, border: `1px solid ${borderColor}` }}
>
{value}
</div>
<p className="text-[11px] mt-1.5 font-medium" style={{ color: "var(--text-muted)" }}>{label}</p>
</div>
);
}
export default function ScorePage() {
return (
<div className="p-6 space-y-6 max-w-2xl overflow-y-auto h-full">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
Score Produit
</h1>
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>
Comprenez comment chaque produit est évalué pour le référencement.
</p>
</div>
{/* Principe fondamental */}
<InfoCard icon={Award} title="Principe fondamental">
<div
className="rounded-[10px] px-4 py-3"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}
>
<p className="text-[13px] font-medium" style={{ color: "var(--accent)" }}>
Un produit excellent sur <strong>une seule dimension</strong> suffit à en faire un bon produit.
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-secondary)" }}>
Un produit qui fait 30% du volume du fournisseur est un bon produit même si sa marge est faible.
</p>
</div>
</InfoCard>
{/* Les 3 axes */}
<InfoCard icon={Layers} title="Les 3 axes d'évaluation">
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
Chaque produit est évalué sur son <strong>poids relatif par rapport au meilleur produit</strong> :
</p>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Quantité", desc: "Volume vendu", icon: "📦" },
{ label: "Chiffre d'Affaires", desc: "Montant total", icon: "💰" },
{ label: "Marge", desc: "Profit généré", icon: "📈" },
].map(({ label, desc, icon }) => (
<div
key={label}
className="px-3 py-3 rounded-[10px] text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<div className="text-lg">{icon}</div>
<p className="text-[12px] font-semibold mt-1" style={{ color: "var(--text-primary)" }}>{label}</p>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{desc}</p>
</div>
))}
</div>
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
Poids = (métrique du produit ÷ métrique du Produit N°1) × 100
</p>
</div>
</InfoCard>
{/* Formule */}
<InfoCard icon={TrendingUp} title="Formule de calcul">
<div className="space-y-4">
<FormulaStep
step={1}
title="Calculer les 3 scores de base (indexés sur le TOP 100)"
formula="score_axe = produit.métrique / top1_fournisseur × 100"
/>
<FormulaStep
step={2}
title="Prendre le meilleur axe (MAX)"
formula="base = MAX(poids_qty, poids_ca, poids_marge)"
/>
<FormulaStep
step={3}
title="Ajouter le bonus polyvalence"
formula="bonus = nb_axes_forts × bonus_par_axe"
/>
<FormulaStep
step={4}
title="Score final"
formula="score = MIN(base + bonus, 100)"
/>
<div
className="rounded-[10px] px-4 py-3 mt-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
<strong>Axe fort</strong> = un axe dont le poids dépasse le seuil (configurable dans Paramètres).
Un produit peut obtenir un bonus s'il excelle sur 2 ou 3 axes à la fois.
</p>
</div>
</div>
</InfoCard>
{/* Signification des couleurs */}
<InfoCard icon={BarChart3} title="Lecture du score">
<div className="flex items-center justify-center gap-6 py-2">
<ScoreBadge value="85" label="Excellent" />
<ScoreBadge value="55" label="Correct" />
<ScoreBadge value="12" label="Faible" />
</div>
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>≥ 80</strong> — Produit stratégique, très forte contribution</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>50 – 79</strong> — Contribution significative, à conserver</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500" />
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}><strong>&lt; 50</strong> — Contribution faible, à réévaluer</p>
</div>
</div>
</InfoCard>
{/* Lien vers les paramètres */}
<Link
href="/settings"
className="flex items-center gap-2 px-4 py-3 rounded-[10px] transition-all hover:gap-3"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--accent)" }}
>
<span className="text-[13px] font-medium">Ajuster les paramètres du score</span>
<ArrowRight className="w-4 h-4 ml-auto" />
</Link>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
"use client";
import { useState, useCallback } from "react";
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, BarChart3, RotateCcw } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
interface OpenRouterModel { id: string; name: string; free: boolean; }
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section
className="rounded-[14px] overflow-hidden"
style={{ border: "1px solid var(--border)", boxShadow: "var(--shadow-sm)" }}
>
<div className="px-5 py-4" style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-elevated)" }}>
<h2 className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>{title}</h2>
{subtitle && <p className="text-[12px] mt-0.5" style={{ color: "var(--text-secondary)" }}>{subtitle}</p>}
</div>
<div className="px-5 py-5 space-y-4" style={{ background: "var(--bg-surface)" }}>
{children}
</div>
</section>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium" style={{ color: "var(--text-secondary)" }}>{label}</label>
{children}
{hint && <p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{hint}</p>}
</div>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const setSeuilAxeFort = useScoreSettingsStore((s) => s.setSeuilAxeFort);
const setBonusParAxe = useScoreSettingsStore((s) => s.setBonusParAxe);
const resetScoreDefaults = useScoreSettingsStore((s) => s.resetDefaults);
const [showKey, setShowKey] = useState(false);
const [apiKey, setApiKey] = useState("");
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle");
const [models, setModels] = useState<OpenRouterModel[]>([]);
const [selectedModel, setSelectedModel] = useState("google/gemini-flash-1.5");
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const fetchModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setModelsStatus("loading");
try {
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setModels(data.models ?? []);
setModelsStatus("ok");
} catch {
setModelsStatus("error");
}
}, []);
const testConnection = async () => {
setDbStatus("testing");
await new Promise((r) => setTimeout(r, 1200));
setDbStatus("error");
};
return (
<div className="space-y-6 max-w-xl">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>Paramètres</h1>
<p className="text-[13px] mt-1" style={{ color: "var(--text-secondary)" }}>Configuration de la connexion et des services.</p>
</div>
{/* PostgreSQL */}
<Section title="PostgreSQL" subtitle="Connexion à votre instance Docker ou distante">
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
<input type="text" placeholder="localhost" className="apple-input font-mono" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Port">
<input type="text" placeholder="5432" defaultValue="5432" className="apple-input font-mono" />
</Field>
<Field label="Base de données">
<input type="text" placeholder="collectflow" className="apple-input font-mono" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Utilisateur">
<input type="text" placeholder="postgres" className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<input type="password" placeholder="••••••••" className="apple-input font-mono" />
</Field>
</div>
<div className="flex items-center gap-2.5">
<input type="checkbox" id="ssl" className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer" />
<label htmlFor="ssl" className="text-[12px] cursor-pointer" style={{ color: "var(--text-secondary)" }}>Connexion SSL</label>
</div>
<div className="flex items-center gap-3">
<button onClick={testConnection} disabled={dbStatus === "testing"} className="apple-btn-secondary">
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
Tester la connexion
</button>
{dbStatus === "error" && (
<span className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Connexion refusée — vérifiez les paramètres</span>
)}
</div>
</Section>
{/* OpenRouter */}
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
placeholder="sk-or-v1-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
>
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchModels(apiKey)}
disabled={!apiKey || modelsStatus === "loading"}
className="apple-btn-secondary whitespace-nowrap"
>
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={models.length === 0}
className="apple-input"
>
{models.length === 0
? <option>— Modèles non chargés —</option>
: models.map((m) => (
<option key={m.id} value={m.id}>
{m.free ? "🆓 " : ""}{m.name}
</option>
))
}
</select>
</Field>
</Section>
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Thème</p>
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Interface claire ou sombre</p>
</div>
{/* Segment control */}
<div className="segment-control">
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
<Sun className="w-3.5 h-3.5" /> Clair
</button>
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
<Moon className="w-3.5 h-3.5" /> Sombre
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>Densité de la grille</p>
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>Hauteur de ligne par défaut</p>
</div>
<select
className="apple-input w-auto"
value={displayDensity}
onChange={(e) => setDisplayDensity(e.target.value as any)}
>
<option value="compact">Compact (32px)</option>
<option value="normal">Normal (40px)</option>
<option value="comfortable">Spacieux (48px)</option>
</select>
</div>
</Section>
{/* Score Produit */}
<Section title="Score Produit" subtitle="Paramètres de l'algorithme d'évaluation des performances">
<Field
label={`Seuil d'axe fort : ${seuilAxeFort.toFixed(1)}%`}
hint="Pourcentage minimum du poids fournisseur pour qu'une dimension (Quantité, CA, Marge) soit considérée comme un point fort du produit."
>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="100"
step="5"
value={seuilAxeFort}
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value))}
className="flex-1 accent-emerald-500 cursor-pointer"
/>
<div className="relative w-20">
<input
type="number"
min="5"
max="100"
step="5"
value={seuilAxeFort}
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value) || 30.0)}
className="apple-input font-mono-nums text-right pr-6"
/>
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
</div>
</div>
</Field>
<Field
label={`Bonus par axe : +${bonusParAxe} pts`}
hint="Points supplémentaires accordés au score final pour chaque axe dépassant le seuil."
>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="20"
step="1"
value={bonusParAxe}
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10))}
className="flex-1 accent-emerald-500 cursor-pointer"
/>
<div className="w-20">
<input
type="number"
min="0"
max="20"
step="1"
value={bonusParAxe}
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10) || 0)}
className="apple-input font-mono-nums text-right"
/>
</div>
</div>
</Field>
<div className="pt-2">
<button
onClick={resetScoreDefaults}
className="flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-emerald-500"
style={{ color: "var(--text-secondary)" }}
>
<RotateCcw className="w-3.5 h-3.5" />
Rétablir les valeurs par défaut
</button>
</div>
</Section>
{/* Save */}
<div className="flex items-center gap-3">
<button
onClick={() => { setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2500); }}
className="apple-btn-primary"
style={{ background: "var(--accent)" }}
>
{saveStatus === "saved" ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{saveStatus === "saved" ? "Sauvegardé !" : "Sauvegarder"}
</button>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export default function SnapshotsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-100">Snapshots</h1>
<p className="text-sm text-slate-400 mt-1">Historique de vos sessions d&apos;arbitrage sauvegardées.</p>
</div>
<div className="border border-dashed border-slate-700 rounded-xl p-12 text-center">
<p className="text-slate-500 text-sm">Aucun snapshot enregistré.</p>
<p className="text-slate-600 text-xs mt-1">Utilisez le bouton <strong className="text-slate-500">«&nbsp;Valider&nbsp;»</strong> dans la grille pour créer votre premier snapshot.</p>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const AnalyzeSchema = z.object({
codein: z.string(),
libelle1: z.string(),
totalCa: z.number(),
tauxMarge: z.number(),
totalQuantite: z.number(),
sales12m: z.record(z.string(), z.number()),
codeGamme: z.string().nullable(),
});
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
En analysant les données de ventes fournies, génère une recommandation de gamme concise (A=Cœur, B=Complémentaire, C=Saisonnier, Z=Sortie).
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Format: "[Recommandation]: [Justification courte basée sur les données]"`;
export async function POST(req: NextRequest) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured" }, { status: 503 });
}
const body = await req.json();
const parsed = AnalyzeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
}
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme } = parsed.data;
// Format monthly sales for the prompt
const monthlySummary = Object.entries(sales12m)
.map(([k, v]) => `${k}: ${Math.round(v)} unités`)
.join(", ");
const userMessage = `Produit: "${libelle1}" (Code: ${codein})
Gamme actuelle: ${codeGamme ?? "Non définie"}
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités
Historique mensuel: ${monthlySummary}
Quelle gamme recommandes-tu et pourquoi ?`;
try {
const response = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow AI Copilot",
},
body: JSON.stringify({
model: "google/gemini-flash-1.5",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 150,
temperature: 0.3,
}),
});
if (!response.ok) {
const err = await response.text();
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
}
const data = await response.json();
const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible.";
return NextResponse.json({ insight, codein });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
const ExportSchema = z.object({
nomFournisseur: z.string(),
rows: z.array(z.object({
codein: z.string(),
libelle1: z.string(),
code3: z.string(),
libelle3: z.string(),
codeGamme: z.string().nullable(),
totalQuantite: z.number(),
totalCa: z.number(),
tauxMarge: z.number(),
// Before gamme for delta
gammeAvant: z.string().nullable().optional(),
})),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = ExportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const { nomFournisseur, rows } = parsed.data;
const workbook = new ExcelJS.Workbook();
workbook.creator = "CollectFlow";
workbook.created = new Date();
const sheet = workbook.addWorksheet("Assortiment", {
views: [{ state: "frozen", ySplit: 1 }],
});
// Header style
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, color: { argb: "FFFFFFFF" } },
fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } },
alignment: { horizontal: "center" },
border: { bottom: { style: "thin", color: { argb: "FF334155" } } },
};
sheet.columns = [
{ header: "Code", key: "codein", width: 14 },
{ header: "Désignation", key: "libelle1", width: 40 },
{ header: "Nomenclature", key: "libelle3", width: 25 },
{ header: "Gamme Avant", key: "gammeAvant", width: 14 },
{ header: "Gamme Après", key: "codeGamme", width: 14 },
{ header: "Delta", key: "delta", width: 10 },
{ header: "Vol. 12m", key: "totalQuantite", width: 12 },
{ header: "CA 12m (€)", key: "totalCa", width: 14 },
{ header: "Marge (%)", key: "tauxMarge", width: 12 },
];
// Apply header style
sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); });
sheet.getRow(1).height = 22;
// Gamme colours
const GAMME_COLORS: Record<string, string> = {
A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519",
};
for (const row of rows) {
const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : "";
const excelRow = sheet.addRow({
codein: row.codein,
libelle1: row.libelle1,
libelle3: row.libelle3,
gammeAvant: row.gammeAvant ?? "—",
codeGamme: row.codeGamme ?? "—",
delta,
totalQuantite: Math.round(row.totalQuantite),
totalCa: parseFloat(row.totalCa.toFixed(2)),
tauxMarge: parseFloat(row.tauxMarge.toFixed(2)),
});
// Colour the "Gamme Après" cell
const gammeCell = excelRow.getCell("codeGamme");
if (row.codeGamme && GAMME_COLORS[row.codeGamme]) {
gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } };
gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
}
// Highlight changed rows
if (delta) {
excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } };
}
// Format numbers
excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"';
excelRow.getCell("tauxMarge").numFmt = '0.00"%"';
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-openrouter-key") || process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
}
try {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
next: { revalidate: 3600 }, // Cache 1h
});
if (!res.ok) {
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
}
const data = await res.json();
// Return simplified list sorted by name
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string } }>)
.map((m) => ({
id: m.id,
name: m.name,
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
}))
.sort((a, b) => {
// Free models first, then alphabetical
if (a.free && !b.free) return -1;
if (!a.free && b.free) return 1;
return a.name.localeCompare(b.name, "fr");
});
return NextResponse.json({ models });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+319
View File
@@ -0,0 +1,319 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════
CollectFlow — Apple-inspired Design System
═══════════════════════════════════════════ */
/* ── Dark Mode (default) ── */
:root {
/* Apple macOS dark exact values */
--bg-base: #000000;
--bg-surface: #111111;
--bg-elevated: #1c1c1e;
--bg-overlay: rgba(28, 28, 30, 0.85);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.12);
--text-primary: #f5f5f7;
--text-secondary: #a1a1a6;
--text-muted: #86868b;
--accent: #2dd4bf;
/* emerald-ish, Apple-tuned */
--accent-bg: rgba(45, 212, 191, 0.10);
--accent-border: rgba(45, 212, 191, 0.20);
/* Semantic UI accents */
--accent-success: #10b981;
--accent-success-bg: rgba(16, 185, 129, 0.10);
--accent-warning: #f59e0b;
--accent-warning-bg: rgba(245, 158, 11, 0.10);
--accent-error: #f43f5e;
--accent-error-bg: rgba(244, 63, 94, 0.10);
/* Primary Action / Brand */
--brand-solid: #10b981;
--brand-hover: #059669;
--action-secondary-bg: #2c2c2e;
--action-secondary-text: #f5f5f7;
--action-secondary-border: rgba(255, 255, 255, 0.1);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.6), 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.7), 0 1px 4px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.6);
}
/* ── Light Mode ── */
.light {
--bg-base: #f5f5f7;
/* Apple light gray */
--bg-surface: #ffffff;
--bg-elevated: #f0f0f5;
--bg-overlay: rgba(255, 255, 255, 0.82);
--border: rgba(0, 0, 0, 0.07);
--border-strong: rgba(0, 0, 0, 0.12);
--text-primary: #1d1d1f;
/* Apple dark */
--text-secondary: #515154;
--text-muted: #86868b;
--accent: #0d9488;
/* deeper teal in light */
--accent-bg: rgba(13, 148, 136, 0.08);
--accent-border: rgba(13, 148, 136, 0.18);
/* Semantic UI accents (Light Mode Adjustments) */
--accent-success: #059669;
--accent-success-bg: rgba(5, 150, 105, 0.08);
--accent-warning: #d97706;
--accent-warning-bg: rgba(217, 119, 6, 0.08);
--accent-error: #e11d48;
--accent-error-bg: rgba(225, 29, 72, 0.08);
/* Primary Action / Brand (Light) */
--brand-solid: #0d9488;
--brand-hover: #0f766e;
--action-secondary-bg: #ffffff;
--action-secondary-text: #1d1d1f;
--action-secondary-border: rgba(0, 0, 0, 0.1);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
}
@layer base {
html {
color-scheme: dark;
/* Force no system scroll jump */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html.light {
color-scheme: light;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-sans, -apple-system, "Inter", system-ui, sans-serif);
font-size: 14px;
line-height: 1.5;
transition: background-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Apple-style focus ring */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
}
/* ── Typography ── */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
/* ── Surfaces ── */
.surface {
background: var(--bg-surface);
}
.elevated {
background: var(--bg-elevated);
}
/* ── Glass panels (sidebar, header) ── */
.glass {
background: var(--bg-overlay);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
}
/* ── Apple border ── */
.border-apple {
border: 1px solid var(--border);
}
.border-apple-strong {
border: 1px solid var(--border-strong);
}
/* ── Tabular numbers & Mono ── */
.tabular-nums {
font-variant-numeric: tabular-nums;
letter-spacing: -0.015em;
}
.font-mono-nums {
font-family: var(--font-sans, system-ui, sans-serif);
font-variant-numeric: tabular-nums;
letter-spacing: -0.015em;
}
/* ── Refined scrollbar ── */
.overflow-auto::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.overflow-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 99px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.light .overflow-auto::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.18);
}
/* ── Heatmap cells ── */
.heatmap-zero {
background: var(--bg-elevated);
color: var(--text-muted);
}
.heatmap-low {
background: rgba(45, 212, 191, 0.08);
color: rgba(45, 212, 191, 0.70);
}
.heatmap-mid {
background: rgba(45, 212, 191, 0.15);
color: rgba(45, 212, 191, 0.90);
}
.heatmap-high {
background: rgba(45, 212, 191, 0.25);
color: #2dd4bf;
}
.light .heatmap-low {
background: rgba(13, 148, 136, 0.08);
color: #0f766e;
}
.light .heatmap-mid {
background: rgba(13, 148, 136, 0.14);
color: #0d9488;
}
.light .heatmap-high {
background: rgba(13, 148, 136, 0.22);
color: #0f766e;
}
/* ── Apple-style input ── */
.apple-input {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
}
.apple-input::placeholder {
color: var(--text-muted);
}
.apple-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-bg);
}
/* ── Apple-style button ── */
.apple-btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
border: none;
}
.apple-btn-primary:hover {
opacity: 0.88;
}
.apple-btn-primary:active {
transform: scale(0.97);
}
.apple-btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.apple-btn-secondary:hover {
background: var(--bg-overlay);
}
/* ── Segment control (theme toggle) ── */
.segment-control {
display: inline-flex;
gap: 2px;
padding: 3px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 9px;
}
.segment-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 14px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.segment-btn.active {
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/shared/theme-provider";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
export const metadata: Metadata = {
title: "CollectFlow — Dashboard Décisionnel",
description: "Révision d'Assortiment Haute Performance",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/grid");
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { Search, X, Sun, Moon } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
export function Header() {
const { resolvedTheme, setTheme } = useTheme();
const setFilter = useGridStore((s) => s.setFilter);
const search = useGridStore((s) => s.filters.search);
const isDark = resolvedTheme === "dark";
return (
<header
className="h-12 flex items-center justify-between px-5 sticky top-0 z-20 glass"
style={{ borderBottom: "1px solid var(--border)" }}
>
{/* Search */}
<div className="relative w-72">
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
style={{ color: "var(--text-muted)" }}
strokeWidth={2}
/>
<input
type="search"
placeholder="Rechercher…"
value={search}
onChange={(e) => setFilter("search", e.target.value)}
className="apple-input pr-8 h-8 text-[13px]"
style={{ paddingLeft: "32px" }}
/>
{search && (
<button
onClick={() => setFilter("search", "")}
className="absolute right-2.5 top-1/2 -translate-y-1/2"
>
<X className="w-3 h-3" style={{ color: "var(--text-muted)" }} />
</button>
)}
</div>
{/* Right controls */}
<div className="flex items-center gap-2">
{/* Theme toggle */}
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Mode clair" : "Mode sombre"}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all duration-150"
style={{ color: "var(--text-secondary)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{isDark
? <Sun className="w-[15px] h-[15px]" strokeWidth={1.8} />
: <Moon className="w-[15px] h-[15px]" strokeWidth={1.8} />}
</button>
{/* Divider */}
<div className="w-px h-5 mx-1" style={{ background: "var(--border)" }} />
{/* User */}
<div className="flex items-center gap-2">
<div className="w-[26px] h-[26px] rounded-full bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center text-[11px] font-bold text-white shadow-sm border border-white/20">
M
</div>
<span className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
Michael
</span>
</div>
</div>
</header>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3 } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
{ icon: FileDown, label: "Exports", href: "/exports" },
{ icon: BarChart3, label: "Score", href: "/score" },
{ icon: Settings, label: "Paramètres", href: "/settings" },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside
className="w-60 flex-shrink-0 flex flex-col h-screen fixed left-0 top-0 z-30 glass"
style={{ borderRight: "1px solid var(--border)" }}
>
{/* Logo */}
<div className="px-5 py-[18px]" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-[8px] bg-emerald-500 dark:bg-emerald-600 flex items-center justify-center shadow-sm border border-white/10">
<Package className="w-[15px] h-[15px] text-white" strokeWidth={2.2} />
</div>
<span className="text-[15px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
CollectFlow
</span>
</div>
</div>
{/* Navigation */}
<nav className="px-2 pt-2.5 space-y-0.5">
{NAV_ITEMS.map(({ icon: Icon, label, href }) => {
const isActive = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
style={{
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
background: isActive ? "var(--bg-elevated)" : "transparent",
}}
className={cn(
"flex items-center gap-2.5 px-3 py-[7px] rounded-[8px] text-[13px] font-medium transition-all duration-150",
!isActive && "hover:bg-[var(--bg-elevated)]"
)}
onMouseEnter={(e) => {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)";
}}
onMouseLeave={(e) => {
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<Icon className="w-[15px] h-[15px] shrink-0" strokeWidth={1.8} />
{label}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="mt-auto px-4 py-3" style={{ borderTop: "1px solid var(--border)" }}>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>v1.0.0-beta</p>
</div>
</aside>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="collectflow-theme"
>
{children}
</NextThemesProvider>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/pg-proxy"; // Or node-postgres if using directly
import { drizzle as nodeDrizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = nodeDrizzle(pool, { schema });
+62
View File
@@ -0,0 +1,62 @@
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
// Identification produit
codein: varchar("codein", { length: 20 }).notNull(),
codeFournisseur: varchar("code_fournisseur", { length: 20 }),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
colisage: numeric("colisage", { precision: 10, scale: 5 }),
// Gamme & Nomenclature
codeGamme: varchar("code_gamme", { length: 20 }),
code3: varchar("code3", { length: 20 }),
libelle3: varchar("libelle3", { length: 500 }),
// Dimension magasin & période
magasin: varchar("magasin", { length: 20 }).notNull(),
codeMagasin: varchar("code_magasin", { length: 10 }),
annee: smallint("annee"),
mois: smallint("mois"),
periode: varchar("periode", { length: 10 }).notNull(),
// Métriques
quantite: numeric("quantite", { precision: 12, scale: 2 }),
montantMvt: numeric("montant_mvt", { precision: 14, scale: 4 }),
margeMvt: numeric("marge_mvt", { precision: 14, scale: 4 }),
// Métadonnées
importedAt: timestamp("imported_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_ventes_produit_magasin_periode").on(table.codein, table.magasin, table.periode),
index("idx_ventes_codein").on(table.codein),
index("idx_ventes_fournisseur").on(table.codeFournisseur),
index("idx_ventes_gamme").on(table.codeGamme),
index("idx_ventes_code3").on(table.code3),
index("idx_ventes_magasin").on(table.magasin),
index("idx_ventes_periode").on(table.periode),
index("idx_ventes_annee_mois").on(table.annee, table.mois),
index("idx_ventes_updated_at").on(table.updatedAt),
];
});
/** Snapshot of a complete arbitrage session (Epic 5) */
export const sessionSnapshots = pgTable("session_snapshots", {
id: serial("id").primaryKey(),
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
magasin: varchar("magasin", { length: 20 }).notNull(),
/** JSON map of codein → { before: GammeCode, after: GammeCode } */
changes: jsonb("changes").notNull(),
/** Summary stats at time of snapshot */
summaryJson: jsonb("summary_json"),
/** Label for the snapshot */
label: text("label"),
createdAt: timestamp("created_at").defaultNow(),
});
@@ -0,0 +1,69 @@
"use client";
import { Bot, Loader2, AlertCircle, Sparkles } from "lucide-react";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import type { ProductRow } from "@/types/grid";
import { cn } from "@/lib/utils";
interface AiInsightBlockProps {
row: ProductRow;
}
export function AiInsightBlock({ row }: AiInsightBlockProps) {
const { insights, analyzeProduct } = useAiCopilotStore();
const insight = insights[row.codein];
const status = insight?.status ?? "idle";
const handleAnalyze = () => {
if (status === "loading") return;
analyzeProduct({
codein: row.codein,
libelle1: row.libelle1,
totalCa: row.totalCa,
tauxMarge: row.tauxMarge,
totalQuantite: row.totalQuantite,
sales12m: row.sales12m,
codeGamme: row.codeGamme,
});
};
if (status === "idle") {
return (
<button
onClick={handleAnalyze}
className="flex items-center gap-1.5 text-[11px] font-medium transition-colors group"
style={{ color: "var(--text-secondary)" }}
>
<Sparkles className="w-3 h-3 text-indigo-500 dark:text-indigo-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors" />
<span className="group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors">Analyser</span>
</button>
);
}
if (status === "loading") {
return (
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="italic">Analyse IA...</span>
</div>
);
}
if (status === "error") {
return (
<div className="flex items-center gap-1.5 text-[11px] text-rose-600 dark:text-rose-500">
<AlertCircle className="w-3 h-3" />
<span>Erreur</span>
</div>
);
}
return (
<div className="flex items-start gap-1.5 group cursor-pointer" onClick={handleAnalyze} title="Cliquer pour re-analyser">
<Bot className="w-3.5 h-3.5 mt-0.5 text-indigo-600 dark:text-indigo-400 shrink-0" />
<span className={cn("text-[11px] italic line-clamp-2 leading-[1.3]")} style={{ color: "var(--text-secondary)" }}>
{insight?.insight}
</span>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import { create } from "zustand";
interface AiInsight {
codein: string;
insight: string;
status: "idle" | "loading" | "done" | "error";
}
interface AiCopilotState {
insights: Record<string, AiInsight>;
analyzeProduct: (payload: {
codein: string;
libelle1: string;
totalCa: number;
tauxMarge: number;
totalQuantite: number;
sales12m: Record<string, number>;
codeGamme: string | null;
}) => Promise<void>;
}
export const useAiCopilotStore = create<AiCopilotState>((set) => ({
insights: {},
analyzeProduct: async (payload) => {
const { codein } = payload;
// Mark as loading
set((s) => ({
insights: { ...s.insights, [codein]: { codein, insight: "", status: "loading" } },
}));
try {
const res = await fetch("/api/ai/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: data.insight, status: "done" },
},
}));
} catch {
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: "Erreur lors de l'analyse.", status: "error" },
},
}));
}
},
}));
+41
View File
@@ -0,0 +1,41 @@
"use server";
import { FOURNISSEURS, generateMockRows } from "@/lib/mock-data";
import type { ProductRow } from "@/types/grid";
/**
* Get the list of all unique suppliers (Mocked for now).
*/
export async function getFournisseurs() {
// Simulating a database latency
await new Promise((r) => setTimeout(r, 100));
return FOURNISSEURS;
}
/**
* Get the list of all magasins (Mocked).
*/
export async function getMagasins() {
await new Promise((r) => setTimeout(r, 50));
return [
{ code: "M001", nom: "BORDEAUX LAC" },
{ code: "M002", nom: "TOULOUSE BLAGNAC" },
{ code: "M003", nom: "NANTES BEAUJOIRE" },
{ code: "M004", nom: "LILLE FLANDRES" },
{ code: "M005", nom: "MARSEILLE VALENTINE" },
];
}
/**
* Get grid data for a specific supplier and store (Mocked for now).
* In SQL:
* - If magasin === "TOTAL": SELECT SUM(quantite), AVG(marge)... GROUP BY produit_id
* - If magasin === "M001": SELECT quantite, marge... WHERE magasin_id = 'M001'
*/
export async function getGridData(codeFournisseur: string, magasin: string = "TOTAL"): Promise<ProductRow[]> {
// Simulating a database latency
await new Promise((r) => setTimeout(r, 300));
// Use the existing mock generator - passing magasin to see how it affects data
return generateMockRows(codeFournisseur, 85);
}
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and, ne, sql } from "drizzle-orm";
import type { ProductRow, GammeCode } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input;
// Get all rows for this supplier (excluding the synthetic TOTAL period)
const rows = await db
.select()
.from(ventesProduits)
.where(
and(
eq(ventesProduits.codeFournisseur, codeFournisseur),
ne(ventesProduits.periode, "TOTAL"),
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
)
);
// Group by codein and aggregate monthly sales
const productMap = new Map<string, ProductRow>();
for (const row of rows) {
const existing = productMap.get(row.codein);
const monthKey = row.periode;
const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0"));
const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0"));
const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0"));
if (existing) {
existing.sales12m[monthKey] = (existing.sales12m[monthKey] ?? 0) + qty;
existing.totalQuantite += qty;
existing.totalCa += ca;
existing.totalMarge += marge;
existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0;
} else {
productMap.set(row.codein, {
codein: row.codein,
codeFournisseur: row.codeFournisseur ?? "",
nomFournisseur: row.nomFournisseur ?? "",
libelle1: row.libelle1 ?? "",
gtin: row.gtin ?? "",
reference: row.reference ?? "",
code3: row.code3 ?? "",
libelle3: row.libelle3 ?? "",
codeGamme: (row.codeGamme as GammeCode | null),
codeGammeDraft: null,
sales12m: { [monthKey]: qty },
totalQuantite: qty,
totalCa: ca,
totalMarge: marge,
tauxMarge: ca > 0 ? (marge / ca) * 100 : 0,
score: 0,
aiRecommendation: null,
});
}
}
return computeProductScores(Array.from(productMap.values()));
}
@@ -0,0 +1,49 @@
"use server";
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
const SaveDraftsSchema = z.object({
changes: z.array(
z.object({
codein: z.string(),
magasin: z.string(),
codeGamme: z.enum(["A", "B", "C", "Z"]),
})
),
});
export async function saveDraftChanges(
raw: unknown
): Promise<{ success: boolean; saved: number; error?: string }> {
const parsed = SaveDraftsSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
}
const { changes } = parsed.data;
try {
// Batch update all rows for given codein × magasin
await Promise.all(
changes.map(({ codein, magasin, codeGamme }) =>
db
.update(ventesProduits)
.set({ codeGamme })
.where(
and(
eq(ventesProduits.codein, codein),
eq(ventesProduits.magasin, magasin)
)
)
)
);
return { success: true, saved: changes.length };
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return { success: false, saved: 0, error: msg };
}
}
@@ -0,0 +1,56 @@
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
const GAMME_OPTIONS: { label: string; code: GammeCode; color: string }[] = [
{ label: "A — Cœur", code: "A", color: "border-emerald-600 text-emerald-400 hover:bg-emerald-900/20" },
{ label: "B — Complémentaire", code: "B", color: "border-blue-600 text-blue-400 hover:bg-blue-900/20" },
{ label: "C — Saisonnier", code: "C", color: "border-amber-600 text-amber-400 hover:bg-amber-900/20" },
{ label: "Z — Sortie", code: "Z", color: "border-rose-600 text-rose-400 hover:bg-rose-900/20" },
];
interface BulkActionToolbarProps {
selectedCodeins: string[];
onClearSelection: () => void;
}
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
const { setDraftGamme } = useGridStore();
if (selectedCodeins.length === 0) return null;
const applyBulk = (code: GammeCode) => {
selectedCodeins.forEach((codein) => setDraftGamme(codein, code));
onClearSelection();
};
return (
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
</span>
<div className="h-4 w-px bg-slate-700/50" />
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-tight">Appliquer en masse :</span>
{GAMME_OPTIONS.map(({ label, code, color }) => (
<button
key={code}
onClick={() => applyBulk(code)}
className={cn(
"px-3 py-1 text-xs font-bold border rounded transition-colors",
color
)}
>
{label}
</button>
))}
<button
onClick={onClearSelection}
className="ml-auto text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Annuler
</button>
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
interface FinancialCellProps {
totalCa: number;
totalMarge: number;
tauxMarge: number;
}
function getMarginColor(pct: number): string {
if (pct >= 45) return "#10b981"; // emerald-500
if (pct >= 20) return "#f59e0b"; // amber-500
return "#f43f5e"; // rose-500
}
export function FinancialCell({ totalCa, totalMarge, tauxMarge }: FinancialCellProps) {
const isHighMargin = tauxMarge >= 45;
const isMedMargin = tauxMarge >= 20 && tauxMarge < 45;
let textAccent = "var(--accent-error)";
let bgAccent = "var(--accent-error-bg)";
if (isHighMargin) {
textAccent = "var(--accent-success)";
bgAccent = "var(--accent-success-bg)";
} else if (isMedMargin) {
textAccent = "var(--accent-warning)";
bgAccent = "var(--accent-warning-bg)";
}
return (
<div
className="flex flex-col items-center justify-center rounded-lg py-1 shadow-sm w-full max-w-[130px] mx-auto transition-colors border-apple"
style={{
background: bgAccent,
borderColor: "transparent"
}}
>
<span
className="font-mono-nums text-[13px] font-bold whitespace-nowrap"
style={{ color: textAccent }}
>
{totalCa.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
<div className="flex items-center gap-1.5 mt-0.5 opacity-80">
<span
className="font-mono-nums text-[10px] px-1 py-0.5 rounded font-bold whitespace-nowrap bg-white/20 dark:bg-black/20"
style={{ color: textAccent }}
>
{tauxMarge.toFixed(1)}%
</span>
<span className="font-mono-nums text-[10px] font-semibold text-slate-500 dark:text-slate-400 whitespace-nowrap">
{totalMarge.toLocaleString("fr-FR", { maximumFractionDigits: 0 })}&nbsp;€
</span>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useGridStore } from "@/features/grid/store/use-grid-store";
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
// Determine color based on label to match the prototype
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
return (
<div className="flex flex-col items-center">
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
</div>
);
}
export function FloatingSummaryBar() {
const { summary, draftChanges } = useGridStore();
const hasDrafts = Object.keys(draftChanges).length > 0;
return (
<div
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
style={{
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
}}
>
<div className="text-xs font-bold px-4 py-2 rounded-full border flex items-center gap-2 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 text-slate-500">
{hasDrafts && (
<span className="ml-2 text-[10px] font-black uppercase text-amber-600 dark:text-amber-500 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded border border-amber-200 dark:border-amber-800">
{Object.keys(draftChanges).length} modif.
</span>
)}
</div>
<div className="flex space-x-12 items-center">
<Stat
label="Volume Total Vendu"
value={Math.round(summary.totalQuantite).toLocaleString("fr-FR")}
/>
<Stat
label="CA Global Généré"
value={summary.totalCa.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
<Stat
label="Taux de Marge Moyen"
value={`${summary.tauxMargeGlobal.toFixed(1)}%`}
sub={summary.totalMarge.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 })}
/>
</div>
<div className="flex space-x-3">
<button
className="px-6 py-3 rounded-xl text-sm font-bold transition-all active:scale-95 border hover:brightness-105"
style={{
background: "var(--action-secondary-bg)",
color: "var(--action-secondary-text)",
borderColor: "var(--action-secondary-border)"
}}
>
Export Excel
</button>
<button
className="px-8 py-3 rounded-xl text-sm font-black transition-all shadow-lg active:scale-95 text-white hover:brightness-110"
style={{
background: "var(--brand-solid)",
}}
>
Valider ce Fournisseur
</button>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
interface GammeSelectProps {
value: GammeCode | null;
isDraft: boolean;
onChange: (gamme: GammeCode) => void;
}
const GAMME_STYLES: Record<GammeCode, string> = {
A: "border-[2px] border-emerald-500 bg-emerald-50 text-emerald-900 dark:border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-100",
B: "border-[2px] border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-900/40 dark:text-blue-100",
C: "border-[2px] border-amber-500 bg-amber-50 text-amber-900 dark:border-amber-400 dark:bg-amber-900/40 dark:text-amber-100",
Z: "border-[2px] border-rose-600 bg-rose-50 text-rose-900 dark:border-rose-500 dark:bg-rose-900/50 dark:text-rose-100",
};
export function GammeSelect({ value, isDraft, onChange }: GammeSelectProps) {
return (
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value as GammeCode)}
className={cn(
"w-full text-xs font-black rounded-lg py-2 px-2 outline-none cursor-pointer shadow-sm transition-all text-center",
"focus:ring-2 focus:ring-brand-500/20",
value
? GAMME_STYLES[value]
: "border-2 border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500",
isDraft && "shadow-[0_0_10px_rgba(251,191,36,0.5)] border-amber-400"
)}
>
<option value="" disabled>—</option>
<option value="A">A - Cœur</option>
<option value="B">B - Standard</option>
<option value="C">C - Optionnel</option>
<option value="Z">Z - Sortie</option>
</select>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store";
import { computeProductScores } from "@/lib/score-engine";
interface GridClientProps {
initialRows: ProductRow[];
codeFournisseur: string;
nomFournisseur: string;
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
magasin: string;
}
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const setRows = useGridStore((s) => s.setRows);
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const { save, hasDrafts, count } = useSaveDrafts(magasin);
useEffect(() => {
// Deep clone to avoid mutating the React prop directly across re-renders
const rowsCopy = JSON.parse(JSON.stringify(initialRows));
const scoredRows = computeProductScores(rowsCopy, { seuilAxeFort, bonusParAxe });
setRows(scoredRows);
}, [initialRows, setRows, seuilAxeFort, bonusParAxe]);
const handleSave = () => {
startTransition(async () => {
const result = await save();
setSaveStatus(result.success ? "success" : "error");
setTimeout(() => setSaveStatus("idle"), 3000);
});
};
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
return (
<div className="space-y-3 pb-20">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Révision d&apos;Assortiment</h1>
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {initialRows.length} références
</p>
</div>
{hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "success" ? (
<CheckCircle className="w-4 h-4" />
) : saveStatus === "error" ? (
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde..."
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
? "Erreur"
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
</button>
)}
</div>
{/* Filters */}
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
{/* Bulk toolbar (contextual) */}
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
{/* Main grid */}
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
/>
{/* Summary bar */}
<FloatingSummaryBar />
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import { Search, X, ChevronDown } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
const GAMME_FILTERS: { label: string; value: GammeCode | null }[] = [
{ label: "Tous", value: null },
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
{ label: "Z", value: "Z" },
];
import { SupplierCombobox } from "./supplier-combobox";
import { StoreCombobox } from "./store-combobox";
import { useRouter, useSearchParams } from "next/navigation";
interface GridFilterBarProps {
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
}
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const { filters, setFilter } = useGridStore();
const router = useRouter();
const searchParams = useSearchParams();
const handleSupplierSelect = (code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("fournisseur", code);
router.push(`/grid?${params.toString()}`);
};
const handleStoreSelect = (code: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("magasin", code);
router.push(`/grid?${params.toString()}`);
};
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Supplier Selector */}
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={searchParams.get("fournisseur")}
onSelect={handleSupplierSelect}
className="w-[280px]"
/>
{/* Store Selector */}
<StoreCombobox
magasins={magasins}
selectedCode={searchParams.get("magasin")}
onSelect={handleStoreSelect}
className="w-[220px]"
/>
{/* Search */}
<div className="relative">
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5"
style={{ color: "var(--text-muted)" }}
/>
<input
type="search"
placeholder="Rechercher (code, désignation...)"
value={filters.search}
onChange={(e) => setFilter("search", e.target.value)}
className="apple-input w-72 pr-8"
style={{ paddingLeft: "32px" }}
/>
{filters.search && (
<button
onClick={() => setFilter("search", "")}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
</button>
)}
</div>
{/* Gamme quick filter */}
<div
className="flex items-center gap-1 p-0.5 rounded-md"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
{GAMME_FILTERS.map(({ label, value }) => (
<button
key={label}
onClick={() => setFilter("codeGamme", value)}
className={cn(
"px-2.5 py-1 text-[11px] font-semibold rounded-[4px] transition-colors",
filters.codeGamme === value
? "shadow-sm"
: "hover:bg-[var(--bg-surface)]"
)}
style={{
background: filters.codeGamme === value ? "var(--bg-surface)" : "transparent",
color: filters.codeGamme === value ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{label}
</button>
))}
</div>
{/* Nomenclature filter */}
<div className="relative">
<select
value={filters.code3 ?? ""}
onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-8 appearance-none"
style={{ width: "180px" }}
>
<option value="">Toutes nomenclatures</option>
</select>
<ChevronDown
className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 pointer-events-none"
style={{ color: "var(--text-muted)" }}
/>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
interface HeatmapCellProps {
value: number | null;
}
function getHeatmapStyle(value: number | null): { background: string; color: string } {
if (value === null || value === 0) {
return { background: "var(--bg-elevated)", color: "var(--text-muted)" };
}
const abs = Math.abs(value);
if (abs > 100) return { background: "rgba(45,212,191,0.22)", color: "var(--accent)" };
if (abs > 30) return { background: "rgba(45,212,191,0.14)", color: "var(--accent)" };
if (abs > 10) return { background: "rgba(45,212,191,0.08)", color: "var(--accent)" };
return { background: "var(--bg-elevated)", color: "var(--text-secondary)" };
}
export function HeatmapCell({ value }: HeatmapCellProps) {
const style = getHeatmapStyle(value);
const display = value === null || value === 0 ? "—" : Math.round(Math.abs(value)).toString();
return (
<div
className="rounded text-center text-[12px] tabular-nums px-0.5 py-0.5 font-bold tracking-tighter transition-colors min-h-[24px] flex items-center justify-center font-mono-nums"
style={style}
title={value !== null ? `${Math.abs(value).toLocaleString("fr-FR")} unités` : "Aucune vente"}
>
{display}
</div>
);
}
@@ -0,0 +1,341 @@
"use client";
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { FinancialCell } from "@/features/grid/components/financial-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block";
function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
const MONTHS_12 = getLast12Months();
function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
}
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
const { rows, draftChanges, setDraftGamme, filters, displayDensity } = useGridStore();
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const tableContainerRef = useRef<HTMLDivElement>(null);
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
const cellPadding = displayDensity === "compact" ? "py-1" : displayDensity === "normal" ? "py-1.5" : "py-2.5";
const handleRowSelectionChange = useCallback(
(updater: React.SetStateAction<RowSelectionState>) => {
setRowSelection(updater);
},
[]
);
// Reset selection when rows change (e.g. supplier change)
useEffect(() => {
setRowSelection({});
}, [rows]);
const prevSelectionRef = useRef<string[]>([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
const selectedCodeins = selectedIdxs
.map((idx) => rows[parseInt(idx)]?.codein ?? "")
.filter(Boolean);
// Only update if selection actually changed to avoid re-render loops & console warnings
const currentString = JSON.stringify(selectedCodeins);
const prevString = JSON.stringify(prevSelectionRef.current);
if (currentString !== prevString) {
prevSelectionRef.current = selectedCodeins;
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [rowSelection, onSelectionChange, rows]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
id: "select",
size: 36,
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={(el) => { if (el) el.indeterminate = table.getIsSomeRowsSelected(); }}
onChange={table.getToggleAllRowsSelectedHandler()}
className="accent-emerald-500 cursor-pointer"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
className="accent-emerald-500 cursor-pointer"
/>
),
},
{
accessorKey: "codein",
header: "Code",
size: 90,
cell: ({ getValue }) => <span className="font-mono-nums font-bold text-[12px] tracking-tight opacity-70" style={{ color: "var(--text-muted)" }}>{getValue<string>()}</span>,
},
{
accessorKey: "libelle1",
header: "Désignation",
size: 280,
cell: ({ getValue }) => (
<span className="text-[13px] font-bold truncate block max-w-[270px]" title={getValue<string>()} style={{ color: "var(--text-primary)" }}>
{getValue<string>()}
</span>
),
},
{
accessorKey: "libelle3",
header: "Famille",
size: 160,
cell: ({ getValue }) => (
<span className="text-[12px] truncate block text-left w-full opacity-70" style={{ color: "var(--text-secondary)" }}>
{getValue<string>()}
</span>
),
},
{
accessorKey: "score",
header: "Score",
size: 60,
cell: ({ getValue }) => {
const val = getValue<number>();
const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500";
return (
<div className={cn("text-center font-black text-[13px]", color)}>
{val}
</div>
);
},
},
...MONTHS_12.map((monthKey) => ({
id: `month_${monthKey}`,
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68,
enableSorting: false,
cell: ({ row }: { row: { original: ProductRow } }) => (
<HeatmapCell value={row.original.sales12m[monthKey] ?? null} />
),
})),
{
accessorKey: "totalQuantite",
header: () => <div className="text-center w-full">Tot. 12m</div>,
size: 90,
cell: ({ getValue }) => (
<div className="mx-auto w-[60px] text-center px-1 font-mono-nums text-sm font-black py-1.5 rounded-md border" style={{
background: "var(--bg-elevated)",
color: "var(--text-primary)",
borderColor: "var(--border-strong)"
}}>
{Math.round(getValue<number>()).toLocaleString("fr-FR")}
</div>
),
},
{
id: "financial",
header: () => <div className="text-center w-full">CA / Marge</div>,
size: 160,
cell: ({ row }) => (
<FinancialCell
totalCa={row.original.totalCa}
totalMarge={row.original.totalMarge}
tauxMarge={row.original.tauxMarge}
/>
),
},
{
id: "gammeInitial",
header: () => <div className="text-center w-full">Init.</div>,
size: 56,
cell: ({ row }) => (
<div className="text-center font-bold text-[12px]" style={{ color: "var(--text-secondary)" }}>
{row.original.codeGamme || "-"}
</div>
),
},
{
id: "gamme",
header: "Gamme",
size: 110,
cell: ({ row }) => {
const effectiveGamme = (draftChanges[row.original.codein] ?? row.original.codeGamme) as GammeCode | null;
return (
<GammeSelect
value={effectiveGamme}
isDraft={!!draftChanges[row.original.codein]}
onChange={(g: GammeCode) => setDraftGamme(row.original.codein, g)}
/>
);
},
},
{
id: "ai",
header: "Recommandation IA",
size: 230,
enableSorting: false,
cell: ({ row }) => <AiInsightBlock row={row.original} />,
},
], [draftChanges, setDraftGamme]);
const table = useReactTable({
data: rows,
columns,
state: { sorting, globalFilter: filters.search, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
});
const { rows: tableRows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight,
overscan: 20,
});
const totalWidth = columns.reduce((s, c) => s + ((c as { size?: number }).size ?? 150), 0);
return (
<div
ref={tableContainerRef}
className="overflow-auto rounded-[12px]"
style={{
height: "calc(100vh - 240px)",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<thead className="sticky top-0 z-10 block" style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{headerGroup.headers.map((header) => {
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "financial" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score";
const size = header.getSize();
return (
<th
key={header.id}
className="px-2 py-2 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap select-none flex items-center"
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
justifyContent: isCenter ? "center" : "flex-start"
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3" />
: <ChevronsUpDown className="w-3 h-3 opacity-30" />
)}
</div>
</th>
);
})}
</tr>
))}
</thead>
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const effectiveGamme = draftChanges[row.original.codein] ?? row.original.codeGamme;
const isSelected = row.getIsSelected();
return (
<tr
key={row.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
onClick={() => row.toggleSelected()}
className={cn(
"absolute w-full flex items-center cursor-pointer transition-colors",
effectiveGamme === "Z" && "opacity-40"
)}
style={{
height: `${rowHeight}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: "1px solid var(--border)",
background: isSelected ? "var(--accent-bg)" : undefined,
borderLeft: isSelected ? "2px solid var(--accent)" : undefined,
}}
>
{row.getVisibleCells().map((cell) => {
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "financial" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score";
const size = cell.column.getSize();
return (
<td
key={cell.id}
className={`px-2 overflow-hidden flex items-center`}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
height: `${rowHeight}px`,
justifyContent: isCenter ? "center" : "flex-start"
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface Store {
code: string;
nom: string;
}
interface StoreComboboxProps {
magasins: Store[];
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
}
export function StoreCombobox({ magasins, selectedCode, onSelect, className }: StoreComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const options = React.useMemo(() => [
{ code: "TOTAL", nom: "Tous les magasins (Total)" },
...magasins
], [magasins]);
const selectedStore = options.find((s) => s.code === (selectedCode || "TOTAL"));
const filtered = React.useMemo(() => {
if (!search) return options;
const s = search.toLowerCase();
return options.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50);
}, [options, search]);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className={cn("relative w-[280px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-blue-500/50 transition-all shadow-sm group"
>
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-3.5 h-3.5 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedStore ? selectedStore.nom : "TOTAL"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher un magasin..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((s) => (
<button
key={s.code}
onClick={() => {
onSelect(s.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
s.code === (selectedCode || "TOTAL")
? "bg-blue-500/10 text-blue-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{s.nom}</span>
<span className="text-[10px] opacity-50 font-mono italic">{s.code === "TOTAL" ? "Agrégation nationale" : `Magasin ID: ${s.code}`}</span>
</div>
{s.code === (selectedCode || "TOTAL") && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
interface Supplier {
code: string;
nom: string;
}
interface SupplierComboboxProps {
fournisseurs: Supplier[];
selectedCode: string | null;
onSelect: (code: string) => void;
className?: string;
}
export function SupplierCombobox({ fournisseurs, selectedCode, onSelect, className }: SupplierComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const selectedSupplier = fournisseurs.find((f) => f.code === selectedCode);
const filtered = React.useMemo(() => {
if (!search) return fournisseurs;
const s = search.toLowerCase();
return fournisseurs.filter(f =>
f.nom.toLowerCase().includes(s) || f.code.toLowerCase().includes(s)
).slice(0, 50); // Limit display for performance
}, [fournisseurs, search]);
// Handle click outside to close
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className={cn("relative w-[300px]", className)} ref={containerRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg hover:border-emerald-500/50 transition-all shadow-sm group"
>
<span className="truncate font-medium text-slate-700 dark:text-slate-200">
{selectedSupplier ? selectedSupplier.nom : "Sélectionner un fournisseur..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
{open && (
<div className="absolute top-full left-0 z-50 w-full mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-bottom border-slate-100 dark:border-slate-800 flex items-center gap-2">
<Search className="w-4 h-4 text-slate-400 shrink-0" />
<input
autoFocus
placeholder="Rechercher..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm py-1 placeholder:text-slate-500 dark:text-white"
/>
</div>
<div className="max-h-[300px] overflow-auto py-1 custom-scrollbar">
{filtered.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-500 text-center italic">
Aucun résultat
</div>
) : (
filtered.map((f) => (
<button
key={f.code}
onClick={() => {
onSelect(f.code);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm transition-colors text-left",
f.code === selectedCode
? "bg-emerald-500/10 text-emerald-600 font-bold"
: "hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300"
)}
>
<div className="flex flex-col min-w-0 pr-2">
<span className="truncate">{f.nom}</span>
<span className="text-[10px] opacity-50 font-mono">Code: {f.code}</span>
</div>
{f.code === selectedCode && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))
)}
{fournisseurs.length > 50 && !search && (
<div className="px-4 py-2 text-[10px] text-center text-slate-400 uppercase tracking-widest font-bold bg-slate-50/50 dark:bg-slate-800/50">
Saisissez pour filtrer les {fournisseurs.length} entrées
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import { SupplierCombobox } from "./supplier-combobox";
import { useRouter } from "next/navigation";
interface Supplier {
code: string;
nom: string;
}
interface SupplierSelectionLandingProps {
fournisseurs: Supplier[];
}
export function SupplierSelectionLanding({ fournisseurs }: SupplierSelectionLandingProps) {
const router = useRouter();
const handleSelect = (code: string) => {
router.push(`/grid?fournisseur=${code}`);
};
return (
<div className="h-full flex flex-col items-center justify-center p-6 text-center animate-in fade-in zoom-in duration-500">
<div className="max-w-md space-y-6">
<div className="space-y-4">
<div className="w-16 h-16 bg-emerald-500/10 rounded-2xl flex items-center justify-center mx-auto mb-2 text-emerald-600">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2" />
</svg>
</div>
<h1 className="text-4xl font-black tracking-tight text-slate-900 dark:text-white">CollectFlow</h1>
<p className="text-lg text-slate-500 dark:text-slate-400">
Optimisez votre assortiment en quelques clics. Sélectionnez un fournisseur pour commencer.
</p>
</div>
<div className="flex justify-center pt-4">
<SupplierCombobox
fournisseurs={fournisseurs}
selectedCode={null}
onSelect={handleSelect}
className="w-[400px] !h-12 text-lg shadow-xl"
/>
</div>
<div className="pt-8 text-[11px] text-slate-400 uppercase tracking-[0.2em] font-bold">
Accès Rapide • {fournisseurs.length} Fournisseurs Disponibles
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, Building2, ChevronRight } from "lucide-react";
interface Supplier {
code: string;
nom: string;
}
export function SupplierSelection({ fournisseurs }: { fournisseurs: Supplier[] }) {
const router = useRouter();
const [search, setSearch] = useState("");
const filtered = fournisseurs.filter((f) =>
f.nom.toLowerCase().includes(search.toLowerCase()) ||
f.code.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="max-w-4xl mx-auto py-12 px-6">
<div className="mb-10 text-center">
<h1 className="text-[32px] font-bold tracking-tight mb-2">Choisir un fournisseur</h1>
<p className="text-[15px]" style={{ color: "var(--text-secondary)" }}>
Sélectionnez un fournisseur pour commencer la révision de l'assortiment.
</p>
</div>
<div className="relative mb-8 max-w-xl mx-auto">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par nom ou code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="apple-input pl-12 h-14 text-lg w-full"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((f) => (
<button
key={f.code}
onClick={() => router.push(`/grid?fournisseur=${f.code}`)}
className="flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 hover:border-emerald-500/50 hover:bg-emerald-50/10 transition-all text-left shadow-sm group active:scale-[0.98]"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-500 group-hover:bg-emerald-500/10 group-hover:text-emerald-500 transition-colors">
<Building2 className="w-6 h-6" />
</div>
<div>
<div className="text-[17px] font-bold tracking-tight text-slate-900 dark:text-white">
{f.nom}
</div>
<div className="text-[13px] font-medium text-slate-500">
Code: {f.code}
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-emerald-500 transition-colors" />
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-20 bg-slate-50/50 dark:bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<p className="text-slate-500 font-medium">Aucun fournisseur trouvé pour "{search}"</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import { useCallback } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid";
/**
* Builds the payload from Zustand draftChanges and calls the Server Action.
* Returns a cleanup function called after successful save.
*/
export function useSaveDrafts(magasin: string) {
const { draftChanges, resetDrafts } = useGridStore();
const save = useCallback(async () => {
const changes = Object.entries(draftChanges).map(([codein, codeGamme]) => ({
codein,
magasin,
codeGamme: codeGamme as GammeCode,
}));
if (changes.length === 0) return { success: true, saved: 0 };
const result = await saveDraftChanges({ changes });
if (result.success) resetDrafts();
return result;
}, [draftChanges, magasin, resetDrafts]);
return { save, hasDrafts: Object.keys(draftChanges).length > 0, count: Object.keys(draftChanges).length };
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
interface GridState {
/** Source data from server */
rows: ProductRow[];
/** Draft edits: codein → new GammeCode */
draftChanges: Record<string, GammeCode>;
filters: GridFilters;
summary: GridSummary;
displayDensity: "compact" | "normal" | "comfortable";
// Actions
setRows: (rows: ProductRow[]) => void;
setDraftGamme: (codein: string, gamme: GammeCode) => void;
resetDrafts: () => void;
setFilter: (key: keyof GridFilters, value: string | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
const active = rows.filter((r) => {
const g = drafts[r.codein] ?? r.codeGamme;
return g !== "Z";
});
const totalCa = active.reduce((s, r) => s + (r.totalCa ?? 0), 0);
const totalMarge = active.reduce((s, r) => s + (r.totalMarge ?? 0), 0);
return {
totalRows: active.length,
totalQuantite: active.reduce((s, r) => s + (r.totalQuantite ?? 0), 0),
totalCa,
totalMarge,
tauxMargeGlobal: totalCa > 0 ? (totalMarge / totalCa) * 100 : 0,
};
}
export const useGridStore = create<GridState>()(
persist(
(set, get) => ({
rows: [],
draftChanges: {},
filters: {
magasin: null,
codeFournisseur: null,
code3: null,
codeGamme: null,
search: "",
},
summary: {
totalRows: 0,
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
tauxMargeGlobal: 0,
},
displayDensity: "normal",
setRows: (rows) => {
set({ rows, summary: computeSummary(rows, get().draftChanges) });
},
setDraftGamme: (codein, gamme) => {
const draftChanges = { ...get().draftChanges, [codein]: gamme };
set({ draftChanges, summary: computeSummary(get().rows, draftChanges) });
},
resetDrafts: () => {
set({ draftChanges: {}, summary: computeSummary(get().rows, {}) });
},
setFilter: (key, value) => {
set((state) => ({ filters: { ...state.filters, [key]: value } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
}),
{
name: "collectflow-grid-storage",
// Only persist filters and display density, do not persist data rows and drafts
partialize: (state) => ({
filters: state.filters,
displayDensity: state.displayDensity,
}),
}
)
);
@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine";
interface ScoreSettingsState extends ScoreSettings {
setSeuilAxeFort: (v: number) => void;
setBonusParAxe: (v: number) => void;
resetDefaults: () => void;
}
export const useScoreSettingsStore = create<ScoreSettingsState>()(
persist(
(set) => ({
...DEFAULT_SCORE_SETTINGS,
setSeuilAxeFort: (v) => set({ seuilAxeFort: v }),
setBonusParAxe: (v) => set({ bonusParAxe: v }),
resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }),
}),
{
name: "collectflow-score-settings",
}
)
);
@@ -0,0 +1,45 @@
"use server";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
nomFournisseur: z.string().optional(),
magasin: z.string(),
label: z.string().optional(),
changes: z.record(z.string(), z.object({
before: z.string().nullable(),
after: z.string(),
})),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
totalMarge: z.number(),
tauxMargeGlobal: z.number(),
}).optional(),
});
export async function saveSnapshot(raw: unknown) {
const parsed = SaveSnapshotSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: "Validation failed: " + parsed.error.message };
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary } = parsed.data;
const [created] = await db
.insert(sessionSnapshots)
.values({
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: label ?? `Snapshot — ${new Date().toLocaleDateString("fr-FR")}`,
changes,
summaryJson: summary ?? null,
})
.returning({ id: sessionSnapshots.id });
return { success: true, snapshotId: created?.id };
}
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github"; // Example provider
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
});
+89
View File
@@ -0,0 +1,89 @@
import type { ProductRow, GammeCode } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
const FOURNISSEURS = Array.from({ length: 125 }).map((_, i) => ({
code: `F${String(i + 1).padStart(3, "0")}`,
nom: [
"HENKEL DISTRIBUTION", "GLAMA INTERNATIONAL", "FRANCO & FILS", "L'ORÉAL PARIS", "UNILEVER SERVICES",
"PROCTER & GAMBLE", "JOHNSON & JOHNSON", "NESTLÉ RETAIL", "DANONE DISTRIBUTION", "FERRERO FRANCE"
][i % 10] + (i > 9 ? ` - Entrepôt ${Math.floor(i / 10)}` : "")
}));
const GAMMES: GammeCode[] = ["A", "A", "A", "B", "B", "C", "Z"];
const NOMENCLATURES = [
{ code: "320211", libelle: "Entretien Maison" },
{ code: "310105", libelle: "Hygiène Corporelle" },
{ code: "410203", libelle: "Cosmétiques Femme" },
{ code: "220401", libelle: "Alimentation Bio" },
{ code: "510302", libelle: "Bricolage & Outillage" },
];
const PRODUITS = [
"Lessive Liquide 3L", "Gel Douche Vanille 500ml", "Shampoing Éclat 400ml",
"Crème Hydratante SPF50", "Désinfectant Multi-Surface 1L", "Liquide Vaisselle Citron 750ml",
"Déodorant Bille 50ml", "Après-Shampooing 300ml", "Mousse Rasage 200ml",
"Dentifrice Blancheur 100ml", "Savon Solide Surgras 100g", "Crème Mains Nourrissante 75ml",
"Nettoyant WC 500ml", "Assouplissant Fleurs Blanches 2L", "Spray Cuisine Dégraissant",
"Gel Hydroalcoolique 500ml", "Lingettes Bébé x72", "Couches T4 x44",
"Lait Corps Karité 400ml", "Masque Visage Argile 100ml", "Fond de Teint N°03",
"Rouge à Lèvres Corail", "Mascara Allongeant Noir", "Eyeliner Fin Brun",
"Vernis Ongles Rose", "Blush Pêche Doux", "Palette Ombres 12 teintes",
"Huile Corps Éclat 150ml", "Sérum Anti-Âge 30ml", "Contour Yeux 15ml",
];
function rnd(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateSales12m(): Record<string, number> {
const sales: Record<string, number> = {};
const now = new Date(2026, 1, 1); // Feb 2026
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`;
// Simulate seasonality + some zeros
const base = rnd(0, 200);
sales[key] = base < 30 ? 0 : base;
}
return sales;
}
export function generateMockRows(codeFournisseur: string = "F001", count: number = 80): ProductRow[] {
const rows: ProductRow[] = [];
const fournisseur = FOURNISSEURS.find((f) => f.code === codeFournisseur) ?? FOURNISSEURS[0];
for (let i = 0; i < count; i++) {
const sales12m = generateSales12m();
const totalQuantite = Object.values(sales12m).reduce((s, v) => s + v, 0);
const prixUnitaire = rnd(3, 45);
const totalCa = totalQuantite * prixUnitaire * (1 + rnd(0, 30) / 100);
const tauxMarge = rnd(12, 58);
const totalMarge = totalCa * tauxMarge / 100;
const nomenclature = NOMENCLATURES[i % NOMENCLATURES.length];
const gamme = GAMMES[Math.floor(Math.random() * GAMMES.length)];
rows.push({
codein: `${fournisseur.code}-${String(i + 1).padStart(4, "0")}`,
codeFournisseur: fournisseur.code,
nomFournisseur: fournisseur.nom,
libelle1: PRODUITS[i % PRODUITS.length],
gtin: `340${String(rnd(100000000, 999999999))}`,
reference: `REF${String(rnd(10000, 99999))}`,
code3: nomenclature.code,
libelle3: nomenclature.libelle,
codeGamme: gamme,
codeGammeDraft: null,
sales12m,
totalQuantite,
totalCa: Math.round(totalCa * 100) / 100,
totalMarge: Math.round(totalMarge * 100) / 100,
tauxMarge,
score: 0, // Computed below by computeProductScores
aiRecommendation: null,
});
}
return computeProductScores(rows);
}
export { FOURNISSEURS };
+69
View File
@@ -0,0 +1,69 @@
/**
* CollectFlow — Score Engine (Proposition B: MAX + Bonus polyvalence)
*
* Calcule le score de performance d'un produit en fonction de son poids
* relatif chez le fournisseur sur 3 axes : Quantité, CA, Marge.
*
* Formule :
* poids = produit.metric / total_fournisseur × 100
* base = MAX(poids_qty, poids_ca, poids_marge)
* bonus = count(poids > seuil) × bonusParAxe
* score = MIN(base + bonus, 100)
*/
import type { ProductRow } from "@/types/grid";
export interface ScoreSettings {
/** Seuil en % pour considérer un axe comme "fort" (défaut: 2.0) */
seuilAxeFort: number;
/** Points bonus par axe dépassant le seuil (défaut: 5) */
bonusParAxe: number;
}
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
seuilAxeFort: 30.0,
bonusParAxe: 10,
};
/**
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
* Les rows sont modifiées en place (champ `score`) et retournées.
*/
export function computeProductScores(
rows: ProductRow[],
settings: ScoreSettings = DEFAULT_SCORE_SETTINGS
): ProductRow[] {
if (rows.length === 0) return rows;
// 1. Trouver les valeurs maximales (les "Top 1") pour indexer 0-100
const maxQty = Math.max(...rows.map((r) => r.totalQuantite ?? 0));
const maxCa = Math.max(...rows.map((r) => r.totalCa ?? 0));
const maxMarge = Math.max(...rows.map((r) => r.totalMarge ?? 0));
// 2. Pour chaque produit, calculer le score relatif au Top 1
for (const row of rows) {
// Score de 0 à 100 sur chaque axe (le Top 1 a 100)
const scoreQty = maxQty > 0 ? ((row.totalQuantite ?? 0) / maxQty) * 100 : 0;
const scoreCa = maxCa > 0 ? ((row.totalCa ?? 0) / maxCa) * 100 : 0;
const scoreMarge = maxMarge > 0 ? ((row.totalMarge ?? 0) / maxMarge) * 100 : 0;
// Base = meilleur score d'axe
const base = Math.max(scoreQty, scoreCa, scoreMarge);
// Bonus polyvalence : combien d'axes dépassent le seuil demandé (en %, c.a.d en score d'axe)
// Attention : settings.seuilAxeFort était pensé pour un % du Total (~2%).
// Maintenant le score max étant 100, un "axe fort" devrait plutôt être un paramètre indexé (ex: > 30% du max).
// On va garder la règle, mais l'utilisateur devra probablement ajuster le seuil dans les paramètres.
let axesForts = 0;
if (scoreQty > settings.seuilAxeFort) axesForts++;
if (scoreCa > settings.seuilAxeFort) axesForts++;
if (scoreMarge > settings.seuilAxeFort) axesForts++;
const bonus = axesForts * settings.bonusParAxe;
row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10;
}
return rows;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+8
View File
@@ -0,0 +1,8 @@
export { auth as middleware } from "@/lib/auth";
export const config = {
// Next.js 16+: use 'matcher' with App Router routes only
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)",
],
};
+71
View File
@@ -0,0 +1,71 @@
/**
* CollectFlow - Core Grid Types
* Maps to the `ventes_produits` table via Drizzle ORM (camelCase).
*/
/** One row in the data grid, representing a product at a store for one period. */
export interface VenteProduit {
id: number;
codein: string;
codeFournisseur: string | null;
nomFournisseur: string | null;
libelle1: string | null;
gtin: string | null;
reference: string | null;
codeGamme: GammeCode | null;
code3: string | null;
libelle3: string | null;
magasin: string;
codeMagasin: string | null;
annee: number | null;
mois: number | null;
periode: string;
quantite: number | null;
montantMvt: number | null;
margeMvt: number | null;
}
/** A/B/C/Z status codes for product categories */
export type GammeCode = "A" | "B" | "C" | "Z";
/** A product row with its 12-month sales history and computed KPIs */
export interface ProductRow {
codein: string;
codeFournisseur: string;
nomFournisseur: string;
libelle1: string;
gtin: string;
reference: string;
code3: string;
libelle3: string;
codeGamme: GammeCode | null;
/** Draft gamme value before saving */
codeGammeDraft: GammeCode | null;
/** 12-month sales quantities, indexed by YYYYMM key */
sales12m: Record<string, number>;
totalQuantite: number;
totalCa: number;
totalMarge: number;
tauxMarge: number;
/** Combined performance score (0-10) */
score: number;
aiRecommendation?: string | null;
}
/** Summary bar totals for the currently visible/filtered rows */
export interface GridSummary {
totalRows: number;
totalQuantite: number;
totalCa: number;
totalMarge: number;
tauxMargeGlobal: number;
}
/** Filters applied to the grid */
export interface GridFilters {
magasin: string | null;
codeFournisseur: string | null;
code3: string | null;
codeGamme: GammeCode | null;
search: string;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}