Compare commits
7 Commits
f6dc5fb07f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 86beb8a5dd | |||
| e794ecceb6 | |||
| 14de88945c | |||
| b169e975bb | |||
| 661d053ea0 | |||
| 816c1c40c6 | |||
| d23f2ab53e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,3 +49,5 @@ prompt
|
|||||||
server.log
|
server.log
|
||||||
# Skills directory
|
# Skills directory
|
||||||
/skills/
|
/skills/
|
||||||
|
cookies.txt
|
||||||
|
*.log
|
||||||
|
|||||||
188
README.md
188
README.md
@@ -1,141 +1,93 @@
|
|||||||
# 🚀 Welcome to Z.ai Code Scaffold
|
# OptiqueStock
|
||||||
|
|
||||||
A modern, production-ready web application scaffold powered by cutting-edge technologies, designed to accelerate your development with [Z.ai](https://chat.z.ai)'s AI-powered coding assistance.
|
Application de gestion de magasin d'optique — full-stack Next.js avec gestion des clients, produits, ventes, achats, atelier et rapports.
|
||||||
|
|
||||||
## ✨ Technology Stack
|
## Modules
|
||||||
|
|
||||||
This scaffold provides a robust foundation built with:
|
| Module | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Accueil** | Dashboard avec accès aux modules |
|
||||||
|
| **Clients** | Gestion des clients, relevés de vision, ordonnances |
|
||||||
|
| **Produits** | Catalogue (montures, verres, lentilles, accessoires), alertes stock, codes QR, images |
|
||||||
|
| **Fournisseurs** | Gestion des fournisseurs |
|
||||||
|
| **Achats & Stock** | Factures d'achat, réception de stock, upload PDF |
|
||||||
|
| **Point de Vente** | Panier, paiements fractionnés, historique des ventes |
|
||||||
|
| **Atelier** | Suivi des commandes de montage (4 statuts : EN_ATTENTE → EN_COURS → TERMINE → PRET) |
|
||||||
|
| **Rapports** | KPIs, graphiques (ventes, catégories, stocks), export CSV |
|
||||||
|
|
||||||
### 🎯 Core Framework
|
## Stack
|
||||||
- **⚡ Next.js 16** - The React framework for production with App Router
|
|
||||||
- **📘 TypeScript 5** - Type-safe JavaScript for better developer experience
|
|
||||||
- **🎨 Tailwind CSS 4** - Utility-first CSS framework for rapid UI development
|
|
||||||
|
|
||||||
### 🧩 UI Components & Styling
|
- **Framework**: Next.js 16 (App Router), React 19, TypeScript 5
|
||||||
- **🧩 shadcn/ui** - High-quality, accessible components built on Radix UI
|
- **Base de données**: SQLite via Prisma ORM
|
||||||
- **🎯 Lucide React** - Beautiful & consistent icon library
|
- **UI**: Tailwind CSS 4, shadcn/ui, lucide-react
|
||||||
- **🌈 Framer Motion** - Production-ready motion library for React
|
- **État & données**: Zustand, TanStack Query, TanStack Table
|
||||||
- **🎨 Next Themes** - Perfect dark mode in 2 lines of code
|
- **Formulaires**: react-hook-form + Zod
|
||||||
|
- **Graphiques**: Recharts
|
||||||
|
- **Autre**: Framer Motion, DND Kit, Sharp, QR Code, Sonner (toasts)
|
||||||
|
|
||||||
### 📋 Forms & Validation
|
## Démarrage rapide
|
||||||
- **🎣 React Hook Form** - Performant forms with easy validation
|
|
||||||
- **✅ Zod** - TypeScript-first schema validation
|
|
||||||
|
|
||||||
### 🔄 State Management & Data Fetching
|
|
||||||
- **🐻 Zustand** - Simple, scalable state management
|
|
||||||
- **🔄 TanStack Query** - Powerful data synchronization for React
|
|
||||||
- **🌐 Fetch** - Promise-based HTTP request
|
|
||||||
|
|
||||||
### 🗄️ Database & Backend
|
|
||||||
- **🗄️ Prisma** - Next-generation TypeScript ORM
|
|
||||||
- **🔐 NextAuth.js** - Complete open-source authentication solution
|
|
||||||
|
|
||||||
### 🎨 Advanced UI Features
|
|
||||||
- **📊 TanStack Table** - Headless UI for building tables and datagrids
|
|
||||||
- **🖱️ DND Kit** - Modern drag and drop toolkit for React
|
|
||||||
- **📊 Recharts** - Redefined chart library built with React and D3
|
|
||||||
- **🖼️ Sharp** - High performance image processing
|
|
||||||
|
|
||||||
### 🌍 Internationalization & Utilities
|
|
||||||
- **🌍 Next Intl** - Internationalization library for Next.js
|
|
||||||
- **📅 Date-fns** - Modern JavaScript date utility library
|
|
||||||
- **🪝 ReactUse** - Collection of essential React hooks for modern development
|
|
||||||
|
|
||||||
## 🎯 Why This Scaffold?
|
|
||||||
|
|
||||||
- **🏎️ Fast Development** - Pre-configured tooling and best practices
|
|
||||||
- **🎨 Beautiful UI** - Complete shadcn/ui component library with advanced interactions
|
|
||||||
- **🔒 Type Safety** - Full TypeScript configuration with Zod validation
|
|
||||||
- **📱 Responsive** - Mobile-first design principles with smooth animations
|
|
||||||
- **🗄️ Database Ready** - Prisma ORM configured for rapid backend development
|
|
||||||
- **🔐 Auth Included** - NextAuth.js for secure authentication flows
|
|
||||||
- **📊 Data Visualization** - Charts, tables, and drag-and-drop functionality
|
|
||||||
- **🌍 i18n Ready** - Multi-language support with Next Intl
|
|
||||||
- **🚀 Production Ready** - Optimized build and deployment settings
|
|
||||||
- **🤖 AI-Friendly** - Structured codebase perfect for AI assistance
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Cloner et configurer
|
||||||
bun install
|
bun install
|
||||||
|
echo 'DATABASE_URL="file:./dev.db"' > .env
|
||||||
|
|
||||||
# Start development server
|
# Base de données
|
||||||
|
bunx prisma generate
|
||||||
|
bunx prisma db push
|
||||||
|
|
||||||
|
# Lancer le serveur de développement
|
||||||
bun run dev
|
bun run dev
|
||||||
|
# → http://localhost:3000
|
||||||
# Build for production
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Start production server
|
|
||||||
bun start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to see your application running.
|
## Scripts
|
||||||
|
|
||||||
## 🤖 Powered by Z.ai
|
| Commande | Description |
|
||||||
|
|---|---|
|
||||||
|
| `bun run dev` | Serveur de développement (port 3000) |
|
||||||
|
| `bun run build` | Build production |
|
||||||
|
| `bun start` | Serveur production |
|
||||||
|
| `bun run lint` | ESLint |
|
||||||
|
| `bun run db:push` | Push Prisma schema → DB |
|
||||||
|
| `bun run db:generate` | Générer client Prisma |
|
||||||
|
| `bun run db:migrate` | Migration Prisma |
|
||||||
|
| `bun run db:reset` | Reset base de données |
|
||||||
|
|
||||||
This scaffold is optimized for use with [Z.ai](https://chat.z.ai) - your AI assistant for:
|
## Structure
|
||||||
|
|
||||||
- **💻 Code Generation** - Generate components, pages, and features instantly
|
|
||||||
- **🎨 UI Development** - Create beautiful interfaces with AI assistance
|
|
||||||
- **🔧 Bug Fixing** - Identify and resolve issues with intelligent suggestions
|
|
||||||
- **📝 Documentation** - Auto-generate comprehensive documentation
|
|
||||||
- **🚀 Optimization** - Performance improvements and best practices
|
|
||||||
|
|
||||||
Ready to build something amazing? Start chatting with Z.ai at [chat.z.ai](https://chat.z.ai) and experience the future of AI-powered development!
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # Next.js App Router pages
|
├── app/
|
||||||
├── components/ # Reusable React components
|
│ ├── page.tsx # SPA — commutation de modules
|
||||||
│ └── ui/ # shadcn/ui components
|
│ ├── layout.tsx # Layout racine
|
||||||
├── hooks/ # Custom React hooks
|
│ ├── globals.css # Styles Tailwind + shadcn
|
||||||
└── lib/ # Utility functions and configurations
|
│ └── api/ # API REST (12 groupes de routes)
|
||||||
|
├── components/
|
||||||
|
│ ├── clients/ # Module Clients
|
||||||
|
│ ├── products/ # Module Produits
|
||||||
|
│ ├── pos/ # Module Point de Vente
|
||||||
|
│ ├── purchases/ # Module Achats
|
||||||
|
│ ├── suppliers/ # Module Fournisseurs
|
||||||
|
│ ├── atelier/ # Module Atelier
|
||||||
|
│ ├── reports/ # Module Rapports
|
||||||
|
│ └── ui/ # Composants shadcn/ui
|
||||||
|
├── hooks/ # Hooks personnalisés
|
||||||
|
└── lib/ # Utilitaires (db, optical-utils, qr-code)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎨 Available Features & Components
|
## API
|
||||||
|
|
||||||
This scaffold includes a comprehensive set of modern web development tools:
|
Toutes les routes sous `/api/` suivent le pattern REST :
|
||||||
|
|
||||||
### 🧩 UI Components (shadcn/ui)
|
- `api/clients`, `api/clients/[id]`, `api/clients/[id]/patients`
|
||||||
- **Layout**: Card, Separator, Aspect Ratio, Resizable Panels
|
- `api/patients`, `api/patients/[id]`, `api/patients/[id]/ordonnances`
|
||||||
- **Forms**: Input, Textarea, Select, Checkbox, Radio Group, Switch
|
- `api/produits`, `api/produits/[id]`, `api/produits/[id]/images`
|
||||||
- **Feedback**: Alert, Toast (Sonner), Progress, Skeleton
|
- `api/fournisseurs`, `api/fournisseurs/[id]`, `api/fournisseurs/[id]/factures`
|
||||||
- **Navigation**: Breadcrumb, Menubar, Navigation Menu, Pagination
|
- `api/achats/factures`, `api/achats/factures/[id]`, `api/achats/factures/[id]/valider`
|
||||||
- **Overlay**: Dialog, Sheet, Popover, Tooltip, Hover Card
|
- `api/pos/products`, `api/pos/clients`, `api/pos/sales`, `api/pos/sales/[id]`
|
||||||
- **Data Display**: Badge, Avatar, Calendar
|
- `api/atelier/orders`, `api/atelier/orders/[id]`
|
||||||
|
- `api/reports/dashboard`, `api/reports/sales`, `api/reports/inventory`, `api/reports/export/*`
|
||||||
|
- `api/fichiers/[id]`
|
||||||
|
|
||||||
### 📊 Advanced Data Features
|
Base de données SQLite auto-contenue — aucun serveur externe requis.
|
||||||
- **Tables**: Powerful data tables with sorting, filtering, pagination (TanStack Table)
|
|
||||||
- **Charts**: Beautiful visualizations with Recharts
|
|
||||||
- **Forms**: Type-safe forms with React Hook Form + Zod validation
|
|
||||||
|
|
||||||
### 🎨 Interactive Features
|
|
||||||
- **Animations**: Smooth micro-interactions with Framer Motion
|
|
||||||
- **Drag & Drop**: Modern drag-and-drop functionality with DND Kit
|
|
||||||
- **Theme Switching**: Built-in dark/light mode support
|
|
||||||
|
|
||||||
### 🔐 Backend Integration
|
|
||||||
- **Authentication**: Ready-to-use auth flows with NextAuth.js
|
|
||||||
- **Database**: Type-safe database operations with Prisma
|
|
||||||
- **API Client**: HTTP requests with Fetch + TanStack Query
|
|
||||||
- **State Management**: Simple and scalable with Zustand
|
|
||||||
|
|
||||||
### 🌍 Production Features
|
|
||||||
- **Internationalization**: Multi-language support with Next Intl
|
|
||||||
- **Image Optimization**: Automatic image processing with Sharp
|
|
||||||
- **Type Safety**: End-to-end TypeScript with Zod validation
|
|
||||||
- **Essential Hooks**: 100+ useful React hooks with ReactUse for common patterns
|
|
||||||
|
|
||||||
## 🤝 Get Started with Z.ai
|
|
||||||
|
|
||||||
1. **Clone this scaffold** to jumpstart your project
|
|
||||||
2. **Visit [chat.z.ai](https://chat.z.ai)** to access your AI coding assistant
|
|
||||||
3. **Start building** with intelligent code generation and assistance
|
|
||||||
4. **Deploy with confidence** using the production-ready setup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built with ❤️ for the developer community. Supercharged by [Z.ai](https://chat.z.ai) 🚀
|
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -42,6 +42,7 @@
|
|||||||
"@tanstack/react-query": "^5.82.0",
|
"@tanstack/react-query": "^5.82.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"bun-types": "^1.3.4",
|
"bun-types": "^1.3.4",
|
||||||
@@ -676,6 +678,8 @@
|
|||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||||
|
|
||||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|
||||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
@@ -832,6 +836,8 @@
|
|||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
|
||||||
|
|
||||||
|
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
allowedDevOrigins: ["192.168.1.30:3000"],
|
||||||
/* config options here */
|
/* config options here */
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"@tanstack/react-query": "^5.82.0",
|
"@tanstack/react-query": "^5.82.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -84,6 +85,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"bun-types": "^1.3.4",
|
"bun-types": "^1.3.4",
|
||||||
|
|||||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
19
public/manifest.json
Normal file
19
public/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "OptiqueStock",
|
||||||
|
"short_name": "OptiqueStock",
|
||||||
|
"description": "Gestion de magasin d'optique",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#020617",
|
||||||
|
"theme_color": "#020617",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/logo.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
82
src/app/api/ai/assistant/route.ts
Normal file
82
src/app/api/ai/assistant/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import ZAI from 'z-ai-web-dev-sdk'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireAuth } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAuth()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const prompt = String(body.prompt || '').trim()
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return NextResponse.json({ error: 'Votre demande est vide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [clients, produits, ventes, lowStock, pendingWorkshop] = await Promise.all([
|
||||||
|
db.client.count(),
|
||||||
|
db.produit.count({ where: { actif: true } }),
|
||||||
|
db.vente.count(),
|
||||||
|
db.produit.findMany({
|
||||||
|
where: {
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
stock: 'asc',
|
||||||
|
},
|
||||||
|
take: 8,
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
designation: true,
|
||||||
|
stock: true,
|
||||||
|
stockMin: true,
|
||||||
|
categorie: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.vente.count({
|
||||||
|
where: {
|
||||||
|
statutAtelier: {
|
||||||
|
in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const storeContext = {
|
||||||
|
clients,
|
||||||
|
produitsActifs: produits,
|
||||||
|
ventes,
|
||||||
|
commandesAtelierEnCours: pendingWorkshop,
|
||||||
|
produitsARevoir: lowStock.filter((item) => item.stock <= item.stockMin),
|
||||||
|
}
|
||||||
|
|
||||||
|
const zai = await ZAI.create()
|
||||||
|
const response = await zai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Tu es un assistant IA pour OptiqueStock, un logiciel de gestion de magasin d optique. Reponds en francais, avec des conseils pratiques, courts et directement exploitables. Ne promets pas d actions automatiques.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Contexte du magasin: ${JSON.stringify(storeContext)}\n\nDemande: ${prompt}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
thinking: { type: 'disabled' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
answer: response.choices[0]?.message?.content || 'Aucune reponse generee.',
|
||||||
|
context: storeContext,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI assistant error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Erreur IA inconnue' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions)
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
36
src/app/api/auth/seed/route.ts
Normal file
36
src/app/api/auth/seed/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const existing = await db.employe.findUnique({
|
||||||
|
where: { email: 'admin@optiquestock.com' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ message: 'Admin already exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash('admin123', 12)
|
||||||
|
|
||||||
|
const employe = await db.employe.create({
|
||||||
|
data: {
|
||||||
|
email: 'admin@optiquestock.com',
|
||||||
|
nom: 'Admin',
|
||||||
|
prenom: 'Admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
motDePasse: hashedPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Admin user created',
|
||||||
|
email: employe.email,
|
||||||
|
password: 'admin123',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Seed error:', error)
|
||||||
|
return NextResponse.json({ error: 'Seed failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/app/api/employes/[id]/route.ts
Normal file
169
src/app/api/employes/[id]/route.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const allowedRoles = ['VENDEUR', 'RESPONSABLE', 'ADMIN'] as const
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifie' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((session.user as any).role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLastActiveAdmin(id: string) {
|
||||||
|
const employe = await db.employe.findUnique({ where: { id } })
|
||||||
|
if (!employe || employe.role !== 'ADMIN' || !employe.actif) return false
|
||||||
|
|
||||||
|
const activeAdmins = await db.employe.count({
|
||||||
|
where: {
|
||||||
|
role: 'ADMIN',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return activeAdmins <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const existing = await db.employe.findUnique({ where: { id } })
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Employe introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.role && !allowedRoles.includes(body.role)) {
|
||||||
|
return NextResponse.json({ error: 'Role invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removingAdminAccess =
|
||||||
|
existing.role === 'ADMIN' &&
|
||||||
|
existing.actif &&
|
||||||
|
((body.role && body.role !== 'ADMIN') || body.actif === false)
|
||||||
|
|
||||||
|
if (removingAdminAccess && await isLastActiveAdmin(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Impossible de retirer le dernier administrateur actif' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.email && body.email.toLowerCase() !== existing.email) {
|
||||||
|
const duplicate = await db.employe.findUnique({
|
||||||
|
where: { email: body.email.toLowerCase() },
|
||||||
|
})
|
||||||
|
if (duplicate) {
|
||||||
|
return NextResponse.json({ error: 'Un employe utilise deja cet email' }, { status: 409 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const employe = await db.employe.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
email: body.email !== undefined ? String(body.email).toLowerCase() : existing.email,
|
||||||
|
nom: body.nom !== undefined ? body.nom : existing.nom,
|
||||||
|
prenom: body.prenom !== undefined ? body.prenom : existing.prenom,
|
||||||
|
role: body.role !== undefined ? body.role : existing.role,
|
||||||
|
actif: body.actif !== undefined ? Boolean(body.actif) : existing.actif,
|
||||||
|
...(body.password ? { motDePasse: await bcrypt.hash(body.password, 12) } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(employe)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating employe:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update employee' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const existing = await db.employe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
ventes: true,
|
||||||
|
facturesAchat: true,
|
||||||
|
paiements: true,
|
||||||
|
patients: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Employe introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isLastActiveAdmin(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Impossible de supprimer le dernier administrateur actif' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHistory =
|
||||||
|
existing._count.ventes > 0 ||
|
||||||
|
existing._count.facturesAchat > 0 ||
|
||||||
|
existing._count.paiements > 0 ||
|
||||||
|
existing._count.patients > 0
|
||||||
|
|
||||||
|
if (hasHistory) {
|
||||||
|
const employe = await db.employe.update({
|
||||||
|
where: { id },
|
||||||
|
data: { actif: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json({ success: true, archived: true, employe })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.employe.delete({ where: { id } })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting employe:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete employee' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/app/api/employes/route.ts
Normal file
107
src/app/api/employes/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const allowedRoles = ['VENDEUR', 'RESPONSABLE', 'ADMIN'] as const
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifie' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((session.user as any).role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const employes = await db.employe.findMany({
|
||||||
|
orderBy: [{ actif: 'desc' }, { nom: 'asc' }, { prenom: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
ventes: true,
|
||||||
|
facturesAchat: true,
|
||||||
|
paiements: true,
|
||||||
|
patients: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(employes)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching employes:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch employees' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
if (!body.email || !body.nom || !body.prenom || !body.password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email, nom, prenom et mot de passe sont obligatoires' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(body.role)) {
|
||||||
|
return NextResponse.json({ error: 'Role invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.employe.findUnique({
|
||||||
|
where: { email: String(body.email).toLowerCase() },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: 'Un employe utilise deja cet email' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const employe = await db.employe.create({
|
||||||
|
data: {
|
||||||
|
email: String(body.email).toLowerCase(),
|
||||||
|
nom: body.nom,
|
||||||
|
prenom: body.prenom,
|
||||||
|
role: body.role,
|
||||||
|
actif: body.actif !== undefined ? Boolean(body.actif) : true,
|
||||||
|
motDePasse: await bcrypt.hash(body.password, 12),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(employe)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating employe:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create employee' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { StatutVente, ModePaiement } from '@prisma/client'
|
import { StatutVente, ModePaiement } from '@prisma/client'
|
||||||
|
import { getCurrentUserId, requireAuth } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// Helper function to generate sale number
|
// Helper function to generate sale number
|
||||||
async function generateSaleNumber(): Promise<string> {
|
async function generateSaleNumber(): Promise<string> {
|
||||||
@@ -55,6 +56,9 @@ export async function GET(request: NextRequest) {
|
|||||||
// POST /api/pos/sales - Create a new sale
|
// POST /api/pos/sales - Create a new sale
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const authError = await requireAuth()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const {
|
const {
|
||||||
clientId,
|
clientId,
|
||||||
@@ -103,6 +107,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const employeId = await getCurrentUserId()
|
||||||
|
|
||||||
// Generate sale number
|
// Generate sale number
|
||||||
const numero = await generateSaleNumber()
|
const numero = await generateSaleNumber()
|
||||||
|
|
||||||
@@ -158,7 +165,7 @@ export async function POST(request: NextRequest) {
|
|||||||
montant: paiement.montant,
|
montant: paiement.montant,
|
||||||
reference: paiement.reference || null,
|
reference: paiement.reference || null,
|
||||||
notes: paiement.notes || null,
|
notes: paiement.notes || null,
|
||||||
employeId: null // Can be updated with authentication
|
employeId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
238
src/app/api/pos/wizard-sale/route.ts
Normal file
238
src/app/api/pos/wizard-sale/route.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { ModePaiement, StatutVente } from '@prisma/client'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getCurrentUserId, requireAuth } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const SERVICE_TVA = 20
|
||||||
|
|
||||||
|
async function generateSaleNumber(): Promise<string> {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const salesThisMonth = await db.vente.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: new Date(year, today.getMonth(), 1),
|
||||||
|
lt: new Date(year, today.getMonth() + 1, 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return `V${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServiceProduct(reference: string, designation: string) {
|
||||||
|
return db.produit.upsert({
|
||||||
|
where: { reference },
|
||||||
|
update: {
|
||||||
|
designation,
|
||||||
|
actif: true,
|
||||||
|
stock: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
reference,
|
||||||
|
designation,
|
||||||
|
categorie: 'SERVICE',
|
||||||
|
prixAchatHT: 0,
|
||||||
|
prixVenteTTC: 0,
|
||||||
|
tva: SERVICE_TVA,
|
||||||
|
stock: 999999,
|
||||||
|
stockMin: 0,
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHt(ttc: number, tva = SERVICE_TVA) {
|
||||||
|
return ttc / (1 + tva / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAuth()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const clientId = body.clientId || null
|
||||||
|
const serviceType = body.serviceType as 'COMBO' | 'REPAIR'
|
||||||
|
const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement
|
||||||
|
const additionalCharges = Math.max(0, Number(body.additionalCharges || 0))
|
||||||
|
const employeId = await getCurrentUserId()
|
||||||
|
|
||||||
|
const lines: Array<{
|
||||||
|
produitId: string
|
||||||
|
quantite: number
|
||||||
|
prixUnitaireHT: number
|
||||||
|
prixUnitaireTTC: number
|
||||||
|
remise: number
|
||||||
|
montantHT: number
|
||||||
|
montantTTC: number
|
||||||
|
decrementStock: boolean
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
const notes: string[] = []
|
||||||
|
|
||||||
|
if (serviceType === 'COMBO') {
|
||||||
|
const productLines = Array.isArray(body.products) ? body.products : []
|
||||||
|
if (productLines.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Selectionnez au moins un produit' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of productLines) {
|
||||||
|
const product = await db.produit.findUnique({ where: { id: item.produitId } })
|
||||||
|
const quantite = Math.max(1, Number(item.quantite || 1))
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return NextResponse.json({ error: 'Produit introuvable' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.stock < quantite) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Stock insuffisant pour ${product.designation}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prixUnitaireTTC = Number(item.prixUnitaireTTC ?? product.prixVenteTTC)
|
||||||
|
const prixUnitaireHT = toHt(prixUnitaireTTC, product.tva)
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
produitId: product.id,
|
||||||
|
quantite,
|
||||||
|
prixUnitaireHT,
|
||||||
|
prixUnitaireTTC,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixUnitaireHT * quantite,
|
||||||
|
montantTTC: prixUnitaireTTC * quantite,
|
||||||
|
decrementStock: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
notes.push('Service: Pack monture + verres')
|
||||||
|
if (body.details) notes.push(`Details: ${body.details}`)
|
||||||
|
} else if (serviceType === 'REPAIR') {
|
||||||
|
const repairType = String(body.repairType || '').trim()
|
||||||
|
const repairDescription = String(body.repairDescription || '').trim()
|
||||||
|
const repairPrice = Math.max(0, Number(body.repairPrice || 0))
|
||||||
|
|
||||||
|
if (!repairType || !repairDescription) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Type et description de reparation requis' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairProduct = await getServiceProduct('SERVICE_REPAIR', 'Service reparation')
|
||||||
|
const prixUnitaireHT = toHt(repairPrice)
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
produitId: repairProduct.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: repairPrice,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixUnitaireHT,
|
||||||
|
montantTTC: repairPrice,
|
||||||
|
decrementStock: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
notes.push(`Service: Reparation - ${repairType}`)
|
||||||
|
notes.push(`Description: ${repairDescription}`)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Service invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalCharges > 0) {
|
||||||
|
const extraProduct = await getServiceProduct('SERVICE_EXTRA', 'Frais supplementaires')
|
||||||
|
const prixUnitaireHT = toHt(additionalCharges)
|
||||||
|
lines.push({
|
||||||
|
produitId: extraProduct.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: additionalCharges,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixUnitaireHT,
|
||||||
|
montantTTC: additionalCharges,
|
||||||
|
decrementStock: false,
|
||||||
|
})
|
||||||
|
notes.push(`Frais supplementaires: ${additionalCharges.toFixed(2)} EUR`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const montantHT = lines.reduce((sum, line) => sum + line.montantHT, 0)
|
||||||
|
const montantTTC = lines.reduce((sum, line) => sum + line.montantTTC, 0)
|
||||||
|
const montantTVA = montantTTC - montantHT
|
||||||
|
const numero = await generateSaleNumber()
|
||||||
|
|
||||||
|
const sale = await db.$transaction(async (tx) => {
|
||||||
|
const vente = await tx.vente.create({
|
||||||
|
data: {
|
||||||
|
numero,
|
||||||
|
clientId,
|
||||||
|
statut: StatutVente.PAYEE,
|
||||||
|
montantHT,
|
||||||
|
montantTVA,
|
||||||
|
montantTTC,
|
||||||
|
remise: 0,
|
||||||
|
notes: notes.join('\n'),
|
||||||
|
employeId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
await tx.ligneVente.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
produitId: line.produitId,
|
||||||
|
quantite: line.quantite,
|
||||||
|
prixUnitaireHT: line.prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: line.prixUnitaireTTC,
|
||||||
|
remise: line.remise,
|
||||||
|
montantHT: line.montantHT,
|
||||||
|
montantTTC: line.montantTTC,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (line.decrementStock) {
|
||||||
|
await tx.produit.update({
|
||||||
|
where: { id: line.produitId },
|
||||||
|
data: {
|
||||||
|
stock: {
|
||||||
|
decrement: line.quantite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.paiement.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
mode: paymentMode,
|
||||||
|
montant: montantTTC,
|
||||||
|
employeId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return vente
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeSale = await db.vente.findUnique({
|
||||||
|
where: { id: sale.id },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paiements: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(completeSale, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wizard sale error:', error)
|
||||||
|
return NextResponse.json({ error: 'Impossible d enregistrer la vente' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import SessionProvider from "@/components/auth/SessionProvider";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -14,38 +16,40 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Z.ai Code Scaffold - AI-Powered Development",
|
title: "OptiqueStock - Gestion de Magasin d'Optique",
|
||||||
description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.",
|
description: "Application de gestion de magasin d'optique : clients, produits, ventes, achats, atelier et rapports.",
|
||||||
keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"],
|
manifest: "/manifest.json",
|
||||||
authors: [{ name: "Z.ai Team" }],
|
appleWebApp: {
|
||||||
icons: {
|
capable: true,
|
||||||
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
|
statusBarStyle: "black-translucent",
|
||||||
},
|
title: "OptiqueStock",
|
||||||
openGraph: {
|
|
||||||
title: "Z.ai Code Scaffold",
|
|
||||||
description: "AI-powered development with modern React stack",
|
|
||||||
url: "https://chat.z.ai",
|
|
||||||
siteName: "Z.ai",
|
|
||||||
type: "website",
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: "summary_large_image",
|
|
||||||
title: "Z.ai Code Scaffold",
|
|
||||||
description: "AI-powered development with modern React stack",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
viewportFit: "cover",
|
||||||
|
themeColor: "#020617",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
>
|
>
|
||||||
{children}
|
<ThemeProvider>
|
||||||
|
<SessionProvider>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
122
src/app/login/page.tsx
Normal file
122
src/app/login/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect } from 'react'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const err = searchParams.get('error')
|
||||||
|
if (err === 'CredentialsSignin') {
|
||||||
|
setError('Email ou mot de passe incorrect')
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const callbackUrl = getLocalCallbackPath(searchParams.get('callbackUrl'))
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError('Email ou mot de passe incorrect')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(callbackUrl)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<Card className="w-full max-w-md mx-4">
|
||||||
|
<CardHeader className="text-center space-y-2">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<div className="p-3 bg-primary rounded-lg">
|
||||||
|
<Eye className="h-8 w-8 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">OptiqueStock</CardTitle>
|
||||||
|
<CardDescription>Connectez-vous pour accéder à l'application</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@optiquestock.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? 'Connexion...' : 'Se connecter'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalCallbackPath(callbackUrl: string | null) {
|
||||||
|
if (!callbackUrl) return '/'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(callbackUrl, window.location.origin)
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}` || '/'
|
||||||
|
} catch {
|
||||||
|
return callbackUrl.startsWith('/') ? callbackUrl : '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<p className="text-gray-500">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
src/app/page.tsx
381
src/app/page.tsx
@@ -1,19 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { signOut, useSession } from 'next-auth/react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
Users,
|
|
||||||
Package,
|
|
||||||
Truck,
|
|
||||||
ShoppingCart,
|
|
||||||
FileText,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BrainCircuit,
|
||||||
Eye,
|
Eye,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Wrench
|
LogOut,
|
||||||
|
Package,
|
||||||
|
PanelTop,
|
||||||
|
ShoppingCart,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
UserCog,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import POSModule from '@/components/pos/POSModule'
|
import POSModule from '@/components/pos/POSModule'
|
||||||
import { ProduitListe } from '@/components/products/ProduitListe'
|
import { ProduitListe } from '@/components/products/ProduitListe'
|
||||||
@@ -22,8 +28,24 @@ import AtelierModule from '@/components/atelier/AtelierModule'
|
|||||||
import { SupplierList } from '@/components/suppliers/SupplierList'
|
import { SupplierList } from '@/components/suppliers/SupplierList'
|
||||||
import PurchaseModule from '@/components/purchases/PurchaseModule'
|
import PurchaseModule from '@/components/purchases/PurchaseModule'
|
||||||
import ReportsModule from '@/components/reports/ReportsModule'
|
import ReportsModule from '@/components/reports/ReportsModule'
|
||||||
|
import { EmployeeManagement } from '@/components/employees/EmployeeManagement'
|
||||||
|
import { AIAssistant } from '@/components/ai/AIAssistant'
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
|
import { SellerWizard } from '@/components/seller-wizard/SellerWizard'
|
||||||
|
|
||||||
type Module = 'HOME' | 'CLIENTS' | 'PRODUITS' | 'FOURNISSEURS' | 'ACHATS' | 'VENTE' | 'RAPPORTS' | 'ATELIER'
|
type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
|
||||||
|
type Module =
|
||||||
|
| 'HOME'
|
||||||
|
| 'CLIENTS'
|
||||||
|
| 'PRODUITS'
|
||||||
|
| 'FOURNISSEURS'
|
||||||
|
| 'ACHATS'
|
||||||
|
| 'VENTE'
|
||||||
|
| 'RAPPORTS'
|
||||||
|
| 'ATELIER'
|
||||||
|
| 'UTILISATEURS'
|
||||||
|
| 'IA'
|
||||||
|
| 'VENDEUR_WIZARD'
|
||||||
|
|
||||||
interface ModuleCard {
|
interface ModuleCard {
|
||||||
id: Module
|
id: Module
|
||||||
@@ -32,15 +54,26 @@ interface ModuleCard {
|
|||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
badge?: string
|
badge?: string
|
||||||
color: string
|
color: string
|
||||||
|
roles: RoleEmploye[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const modules: ModuleCard[] = [
|
const modules: ModuleCard[] = [
|
||||||
|
{
|
||||||
|
id: 'VENDEUR_WIZARD',
|
||||||
|
title: 'Vendeur Express',
|
||||||
|
description: 'Parcours rapide client, service, recu',
|
||||||
|
icon: <PanelTop className="h-8 w-8" />,
|
||||||
|
badge: 'Tablette',
|
||||||
|
color: 'bg-rose-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'CLIENTS',
|
id: 'CLIENTS',
|
||||||
title: 'Gestion Clients',
|
title: 'Gestion Clients',
|
||||||
description: 'Fiches clients, mesures de vision, ordonnances',
|
description: 'Fiches clients, mesures de vision, ordonnances',
|
||||||
icon: <Users className="h-8 w-8" />,
|
icon: <Users className="h-8 w-8" />,
|
||||||
color: 'bg-blue-500'
|
color: 'bg-blue-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'PRODUITS',
|
id: 'PRODUITS',
|
||||||
@@ -48,21 +81,24 @@ const modules: ModuleCard[] = [
|
|||||||
description: 'Catalogue, stock, images, QR codes',
|
description: 'Catalogue, stock, images, QR codes',
|
||||||
icon: <Package className="h-8 w-8" />,
|
icon: <Package className="h-8 w-8" />,
|
||||||
badge: 'Alertes',
|
badge: 'Alertes',
|
||||||
color: 'bg-emerald-500'
|
color: 'bg-emerald-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'FOURNISSEURS',
|
id: 'FOURNISSEURS',
|
||||||
title: 'Fournisseurs',
|
title: 'Fournisseurs',
|
||||||
description: 'Gestion des fournisseurs et contacts',
|
description: 'Gestion des fournisseurs et contacts',
|
||||||
icon: <Truck className="h-8 w-8" />,
|
icon: <Truck className="h-8 w-8" />,
|
||||||
color: 'bg-orange-500'
|
color: 'bg-orange-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ACHATS',
|
id: 'ACHATS',
|
||||||
title: 'Achats & Stock',
|
title: 'Achats & Stock',
|
||||||
description: 'Réception, factures fournisseurs, entrées stock',
|
description: 'Reception, factures fournisseurs, entrees stock',
|
||||||
icon: <ShoppingCart className="h-8 w-8" />,
|
icon: <ShoppingCart className="h-8 w-8" />,
|
||||||
color: 'bg-purple-500'
|
color: 'bg-purple-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'VENTE',
|
id: 'VENTE',
|
||||||
@@ -70,7 +106,8 @@ const modules: ModuleCard[] = [
|
|||||||
description: 'Encaissement, facturation, POS',
|
description: 'Encaissement, facturation, POS',
|
||||||
icon: <ShoppingCart className="h-8 w-8" />,
|
icon: <ShoppingCart className="h-8 w-8" />,
|
||||||
badge: 'Actif',
|
badge: 'Actif',
|
||||||
color: 'bg-green-500'
|
color: 'bg-green-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ATELIER',
|
id: 'ATELIER',
|
||||||
@@ -78,41 +115,107 @@ const modules: ModuleCard[] = [
|
|||||||
description: 'Montage de lunettes, commandes en cours',
|
description: 'Montage de lunettes, commandes en cours',
|
||||||
icon: <Wrench className="h-8 w-8" />,
|
icon: <Wrench className="h-8 w-8" />,
|
||||||
badge: 'En cours',
|
badge: 'En cours',
|
||||||
color: 'bg-amber-500'
|
color: 'bg-amber-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'RAPPORTS',
|
id: 'RAPPORTS',
|
||||||
title: 'Rapports',
|
title: 'Rapports',
|
||||||
description: 'Statistiques, exports Excel/CSV/PDF',
|
description: 'Statistiques, exports Excel/CSV/PDF',
|
||||||
icon: <BarChart3 className="h-8 w-8" />,
|
icon: <BarChart3 className="h-8 w-8" />,
|
||||||
color: 'bg-cyan-500'
|
color: 'bg-cyan-500',
|
||||||
}
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'UTILISATEURS',
|
||||||
|
title: 'Utilisateurs',
|
||||||
|
description: 'Employes, roles et niveaux d acces',
|
||||||
|
icon: <UserCog className="h-8 w-8" />,
|
||||||
|
color: 'bg-indigo-500',
|
||||||
|
roles: ['ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'IA',
|
||||||
|
title: 'Assistant IA',
|
||||||
|
description: 'Conseils, priorites et aide a la decision',
|
||||||
|
icon: <BrainCircuit className="h-8 w-8" />,
|
||||||
|
badge: 'Nouveau',
|
||||||
|
color: 'bg-sky-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
const [currentModule, setCurrentModule] = useState<Module>('HOME')
|
const [currentModule, setCurrentModule] = useState<Module>('HOME')
|
||||||
|
|
||||||
|
const currentRole = ((session?.user as any)?.role || 'VENDEUR') as RoleEmploye
|
||||||
|
const visibleModules = modules.filter((module) => module.roles.includes(currentRole))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [status, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = modules.find((module) => module.id === currentModule)
|
||||||
|
if (current && !current.roles.includes(currentRole)) {
|
||||||
|
setCurrentModule('HOME')
|
||||||
|
}
|
||||||
|
}, [currentModule, currentRole])
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') return null
|
||||||
|
|
||||||
|
const moduleInfo = modules.find((module) => module.id === currentModule)
|
||||||
|
|
||||||
|
const moduleHeader = (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour a l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 rounded-lg p-4 ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const renderModule = () => {
|
const renderModule = () => {
|
||||||
if (currentModule === 'HOME') {
|
if (currentModule === 'HOME') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="text-center space-y-2">
|
<div className="space-y-2 text-center">
|
||||||
<h1 className="text-4xl font-bold text-gray-900">OptiqueStock</h1>
|
<h1 className="text-4xl font-bold text-foreground">OptiqueStock</h1>
|
||||||
<p className="text-lg text-gray-600">Système de Gestion de Magasin d'Optique</p>
|
<p className="text-lg text-muted-foreground">
|
||||||
|
Systeme de Gestion de Magasin d'Optique
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{modules.map((module) => (
|
{visibleModules.map((module) => (
|
||||||
<Card
|
<Card
|
||||||
key={module.id}
|
key={module.id}
|
||||||
className="group cursor-pointer transition-all duration-200 hover:shadow-lg hover:scale-105 border-2 hover:border-primary"
|
className="group cursor-pointer border-2 transition-all duration-200 hover:scale-105 hover:border-primary hover:shadow-lg"
|
||||||
onClick={() => setCurrentModule(module.id)}
|
onClick={() => setCurrentModule(module.id)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className={`flex items-center justify-between mb-2`}>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className={`p-3 rounded-lg ${module.color} text-white`}>
|
<div className={`rounded-lg p-3 ${module.color} text-white`}>{module.icon}</div>
|
||||||
{module.icon}
|
|
||||||
</div>
|
|
||||||
{module.badge && (
|
{module.badge && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{module.badge}
|
{module.badge}
|
||||||
@@ -120,9 +223,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">{module.title}</CardTitle>
|
<CardTitle className="text-lg">{module.title}</CardTitle>
|
||||||
<CardDescription className="text-sm">
|
<CardDescription className="text-sm">{module.description}</CardDescription>
|
||||||
{module.description}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -131,243 +232,139 @@ export default function Home() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleInfo = modules.find(m => m.id === currentModule)
|
|
||||||
|
|
||||||
// Render Client Management module if selected
|
|
||||||
if (currentModule === 'CLIENTS') {
|
if (currentModule === 'CLIENTS') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ClientList />
|
<ClientList />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render POS module if selected
|
|
||||||
if (currentModule === 'VENTE') {
|
if (currentModule === 'VENTE') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<POSModule />
|
<POSModule />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Products module if selected
|
|
||||||
if (currentModule === 'PRODUITS') {
|
if (currentModule === 'PRODUITS') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ProduitListe />
|
<ProduitListe />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Atelier module if selected
|
|
||||||
if (currentModule === 'ATELIER') {
|
if (currentModule === 'ATELIER') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AtelierModule />
|
<AtelierModule />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Suppliers module if selected
|
|
||||||
if (currentModule === 'FOURNISSEURS') {
|
if (currentModule === 'FOURNISSEURS') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SupplierList />
|
<SupplierList />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Purchases module if selected
|
|
||||||
if (currentModule === 'ACHATS') {
|
if (currentModule === 'ACHATS') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PurchaseModule />
|
<PurchaseModule />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Reports module if selected
|
|
||||||
if (currentModule === 'RAPPORTS') {
|
if (currentModule === 'RAPPORTS') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
{moduleHeader}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ReportsModule />
|
<ReportsModule />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (currentModule === 'UTILISATEURS') {
|
||||||
<div className="space-y-6">
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="space-y-6">
|
||||||
<Button
|
{moduleHeader}
|
||||||
variant="outline"
|
<EmployeeManagement />
|
||||||
onClick={() => setCurrentModule('HOME')}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
|
||||||
Retour à l'accueil
|
|
||||||
</Button>
|
|
||||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
|
||||||
{moduleInfo?.icon}
|
|
||||||
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<Card className="border-2 border-dashed">
|
if (currentModule === 'IA') {
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
return (
|
||||||
<div className={`p-6 rounded-full ${moduleInfo?.color} bg-opacity-10 mb-4`}>
|
<div className="space-y-6">
|
||||||
<Eye className="h-16 w-16 text-gray-400" />
|
{moduleHeader}
|
||||||
</div>
|
<AIAssistant />
|
||||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">
|
</div>
|
||||||
Module en développement
|
)
|
||||||
</h3>
|
}
|
||||||
<p className="text-gray-500 text-center max-w-md">
|
|
||||||
Le module <strong>{moduleInfo?.title}</strong> est actuellement en cours de développement.
|
if (currentModule === 'VENDEUR_WIZARD') {
|
||||||
Veuillez revenir ultérieurement.
|
return (
|
||||||
</p>
|
<div className="space-y-6">
|
||||||
</CardContent>
|
{moduleHeader}
|
||||||
</Card>
|
<SellerWizard />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<header className="bg-white border-b shadow-sm">
|
<header className="border-b bg-card shadow-sm">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-primary rounded-lg">
|
<div className="rounded-lg bg-primary p-2">
|
||||||
<Eye className="h-6 w-6 text-primary-foreground" />
|
<Eye className="h-6 w-6 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900">OptiqueStock</h1>
|
<h1 className="text-xl font-bold text-foreground">OptiqueStock</h1>
|
||||||
<p className="text-xs text-gray-500">Gestion de Magasin d'Optique</p>
|
<p className="text-xs text-muted-foreground">Gestion de Magasin d'Optique</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="text-sm">
|
<ThemeToggle />
|
||||||
v1.0.0
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
</Badge>
|
<User className="h-4 w-4" />
|
||||||
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
|
<span className="hidden sm:inline">{session?.user?.name}</span>
|
||||||
OP
|
<Badge variant="secondary">{currentRole}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => signOut({ redirect: false }).then(() => router.replace('/login'))}
|
||||||
|
title="Se deconnecter"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">{renderModule()}</main>
|
||||||
{renderModule()}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="bg-white border-t mt-auto">
|
<footer className="mt-auto border-t bg-card">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
<div className="flex items-center justify-center">
|
||||||
<p className="text-sm text-gray-500">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
© 2024 OptiqueStock. Tous droits réservés.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
|
||||||
<span>Support: support@optiquestock.com</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Version 1.0.0</span>
|
<span>Version 1.0.0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
116
src/components/ai/AIAssistant.tsx
Normal file
116
src/components/ai/AIAssistant.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Bot, Loader2, Send, Sparkles } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
|
const quickPrompts = [
|
||||||
|
'Resume les priorites du magasin pour aujourd hui.',
|
||||||
|
'Quels produits dois-je recommander de reapprovisionner ?',
|
||||||
|
'Donne-moi 5 idees pour ameliorer les ventes cette semaine.',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AIAssistant() {
|
||||||
|
const [prompt, setPrompt] = useState(quickPrompts[0])
|
||||||
|
const [answer, setAnswer] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function askAssistant() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setAnswer('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/assistant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Assistant IA indisponible')
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnswer(data.answer)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-cyan-500 p-3 text-white">
|
||||||
|
<Sparkles className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Assistant IA</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Analyse le contexte du magasin et propose des actions rapides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Demandes rapides</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{quickPrompts.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
variant={prompt === item ? 'default' : 'outline'}
|
||||||
|
className="h-auto w-full justify-start whitespace-normal text-left"
|
||||||
|
onClick={() => setPrompt(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
Conseiller OptiqueStock
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
className="min-h-32"
|
||||||
|
placeholder="Posez une question sur le stock, les ventes, les priorites..."
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={askAssistant} disabled={loading || !prompt.trim()} className="gap-2">
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Demander
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{answer && (
|
||||||
|
<div className="rounded-md border bg-muted/40 p-4">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-6">{answer}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/components/auth/SessionProvider.tsx
Normal file
7
src/components/auth/SessionProvider.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
|
||||||
|
|
||||||
|
export default function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
|
||||||
|
}
|
||||||
468
src/components/employees/EmployeeManagement.tsx
Normal file
468
src/components/employees/EmployeeManagement.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Plus, Search, Shield, Trash2, UserCog, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
nom: string
|
||||||
|
prenom: string
|
||||||
|
role: RoleEmploye
|
||||||
|
actif: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
_count?: {
|
||||||
|
ventes: number
|
||||||
|
facturesAchat: number
|
||||||
|
paiements: number
|
||||||
|
patients: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabels: Record<RoleEmploye, string> = {
|
||||||
|
VENDEUR: 'Vendeur',
|
||||||
|
RESPONSABLE: 'Responsable',
|
||||||
|
ADMIN: 'Administrateur',
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
email: '',
|
||||||
|
nom: '',
|
||||||
|
prenom: '',
|
||||||
|
role: 'VENDEUR' as RoleEmploye,
|
||||||
|
actif: true,
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeManagement() {
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmployees()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredEmployees = useMemo(() => {
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
return employees.filter((employee) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!term ||
|
||||||
|
employee.nom.toLowerCase().includes(term) ||
|
||||||
|
employee.prenom.toLowerCase().includes(term) ||
|
||||||
|
employee.email.toLowerCase().includes(term) ||
|
||||||
|
roleLabels[employee.role].toLowerCase().includes(term)
|
||||||
|
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === 'all' ||
|
||||||
|
(statusFilter === 'active' && employee.actif) ||
|
||||||
|
(statusFilter === 'inactive' && !employee.actif)
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [employees, searchTerm, statusFilter])
|
||||||
|
|
||||||
|
const activeCount = employees.filter((employee) => employee.actif).length
|
||||||
|
const adminCount = employees.filter((employee) => employee.actif && employee.role === 'ADMIN').length
|
||||||
|
|
||||||
|
async function fetchEmployees() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/employes')
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Erreur lors du chargement des employes')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmployees(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditingEmployee(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setError('')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(employee: Employee) {
|
||||||
|
setEditingEmployee(employee)
|
||||||
|
setForm({
|
||||||
|
email: employee.email,
|
||||||
|
nom: employee.nom,
|
||||||
|
prenom: employee.prenom,
|
||||||
|
role: employee.role,
|
||||||
|
actif: employee.actif,
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmployee(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
email: form.email,
|
||||||
|
nom: form.nom,
|
||||||
|
prenom: form.prenom,
|
||||||
|
role: form.role,
|
||||||
|
actif: form.actif,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
editingEmployee ? `/api/employes/${editingEmployee.id}` : '/api/employes',
|
||||||
|
{
|
||||||
|
method: editingEmployee ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Enregistrement impossible')
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingEmployee(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
await fetchEmployees()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEmployee(employee: Employee) {
|
||||||
|
if (!confirm(`Supprimer ou desactiver ${employee.prenom} ${employee.nom} ?`)) return
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
const response = await fetch(`/api/employes/${employee.id}`, { method: 'DELETE' })
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Suppression impossible')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchEmployees()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Utilisateurs et acces</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Gere les employes, leur statut et leur niveau d'acces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={openCreateDialog} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nouvel employe
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingEmployee ? 'Modifier un employe' : 'Nouvel employe'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Les administrateurs peuvent gerer les utilisateurs et les acces.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={saveEmployee} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prenom">Prenom</Label>
|
||||||
|
<Input
|
||||||
|
id="prenom"
|
||||||
|
value={form.prenom}
|
||||||
|
onChange={(event) => setForm({ ...form, prenom: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nom">Nom</Label>
|
||||||
|
<Input
|
||||||
|
id="nom"
|
||||||
|
value={form.nom}
|
||||||
|
onChange={(event) => setForm({ ...form, nom: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
{editingEmployee ? 'Nouveau mot de passe' : 'Mot de passe'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||||
|
required={!editingEmployee}
|
||||||
|
placeholder={editingEmployee ? 'Laisser vide pour conserver' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Niveau d'acces</Label>
|
||||||
|
<Select
|
||||||
|
value={form.role}
|
||||||
|
onValueChange={(value: RoleEmploye) => setForm({ ...form, role: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="VENDEUR">Vendeur</SelectItem>
|
||||||
|
<SelectItem value="RESPONSABLE">Responsable</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">Administrateur</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<Label>Compte actif</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Autorise la connexion</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.actif}
|
||||||
|
onCheckedChange={(checked) => setForm({ ...form, actif: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
|
<p className="text-2xl font-bold">{employees.length}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Comptes actifs</p>
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">{activeCount}</p>
|
||||||
|
</div>
|
||||||
|
<UserCog className="h-8 w-8 text-emerald-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Administrateurs</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{adminCount}</p>
|
||||||
|
</div>
|
||||||
|
<Shield className="h-8 w-8 text-blue-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !dialogOpen && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Recherche et filtres</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par nom, email ou role..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'active' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
>
|
||||||
|
Actifs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'inactive' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
>
|
||||||
|
Inactifs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ScrollArea className="h-[560px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Employe</TableHead>
|
||||||
|
<TableHead>Acces</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead>Activite liee</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-8 text-center">
|
||||||
|
Chargement...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredEmployees.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||||
|
Aucun employe trouve
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredEmployees.map((employee) => (
|
||||||
|
<TableRow key={employee.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">
|
||||||
|
{employee.prenom} {employee.nom}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{employee.email}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={employee.role === 'ADMIN' ? 'default' : 'secondary'}>
|
||||||
|
{roleLabels[employee.role]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={employee.actif ? 'default' : 'secondary'}>
|
||||||
|
{employee.actif ? 'Actif' : 'Inactif'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{(employee._count?.ventes || 0) + (employee._count?.facturesAchat || 0)} operations
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEditDialog(employee)}>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteEmployee(employee)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
875
src/components/seller-wizard/SellerWizard.tsx
Normal file
875
src/components/seller-wizard/SellerWizard.tsx
Normal file
@@ -0,0 +1,875 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
Expand,
|
||||||
|
FileText,
|
||||||
|
Package,
|
||||||
|
Plus,
|
||||||
|
Printer,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type Step = 'client' | 'service' | 'details' | 'final'
|
||||||
|
type ServiceType = 'COMBO' | 'REPAIR'
|
||||||
|
type PaymentMode = 'ESPECES' | 'CARTE' | 'CHEQUE' | 'VIREMENT' | 'BON_CAISSE'
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
prenom: string
|
||||||
|
telephone: string
|
||||||
|
email?: string | null
|
||||||
|
adresse?: string | null
|
||||||
|
ville?: string | null
|
||||||
|
codePostal?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
categorie: string
|
||||||
|
prixVenteTTC: number
|
||||||
|
tva: number
|
||||||
|
stock: number
|
||||||
|
marque?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedProduct {
|
||||||
|
produitId: string
|
||||||
|
quantite: number
|
||||||
|
prixUnitaireTTC: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sale {
|
||||||
|
id: string
|
||||||
|
numero: string
|
||||||
|
date: string
|
||||||
|
montantHT: number
|
||||||
|
montantTVA: number
|
||||||
|
montantTTC: number
|
||||||
|
notes?: string | null
|
||||||
|
client?: Client | null
|
||||||
|
lignes: Array<{
|
||||||
|
quantite: number
|
||||||
|
prixUnitaireTTC: number
|
||||||
|
montantTTC: number
|
||||||
|
produit: {
|
||||||
|
designation: string
|
||||||
|
reference: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
paiements: Array<{
|
||||||
|
mode: PaymentMode
|
||||||
|
montant: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientFields = [
|
||||||
|
{ key: 'prenom', label: 'Prenom', required: true, inputMode: 'text' },
|
||||||
|
{ key: 'nom', label: 'Nom', required: true, inputMode: 'text' },
|
||||||
|
{ key: 'telephone', label: 'Telephone', required: true, inputMode: 'tel' },
|
||||||
|
{ key: 'email', label: 'Email', required: false, inputMode: 'email' },
|
||||||
|
{ key: 'adresse', label: 'Adresse', required: false, inputMode: 'text' },
|
||||||
|
{ key: 'ville', label: 'Ville', required: false, inputMode: 'text' },
|
||||||
|
{ key: 'codePostal', label: 'Code postal', required: false, inputMode: 'numeric' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const paymentLabels: Record<PaymentMode, string> = {
|
||||||
|
ESPECES: 'Especes',
|
||||||
|
CARTE: 'Carte',
|
||||||
|
CHEQUE: 'Cheque',
|
||||||
|
VIREMENT: 'Virement',
|
||||||
|
BON_CAISSE: 'Bon de caisse',
|
||||||
|
}
|
||||||
|
|
||||||
|
function currency(value: number) {
|
||||||
|
return `${value.toFixed(2)} EUR`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SellerWizard() {
|
||||||
|
const [step, setStep] = useState<Step>('client')
|
||||||
|
const [clients, setClients] = useState<Client[]>([])
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [clientSearch, setClientSearch] = useState('')
|
||||||
|
const [productSearch, setProductSearch] = useState('')
|
||||||
|
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
|
||||||
|
const [serviceType, setServiceType] = useState<ServiceType | null>(null)
|
||||||
|
const [repairType, setRepairType] = useState('')
|
||||||
|
const [repairDescription, setRepairDescription] = useState('')
|
||||||
|
const [repairPrice, setRepairPrice] = useState(0)
|
||||||
|
const [comboDetails, setComboDetails] = useState('')
|
||||||
|
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
|
||||||
|
const [additionalCharges, setAdditionalCharges] = useState(0)
|
||||||
|
const [paymentMode, setPaymentMode] = useState<PaymentMode>('ESPECES')
|
||||||
|
const [sale, setSale] = useState<Sale | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [createClientOpen, setCreateClientOpen] = useState(false)
|
||||||
|
const [clientFieldIndex, setClientFieldIndex] = useState(0)
|
||||||
|
const [keyboardOpen, setKeyboardOpen] = useState(false)
|
||||||
|
const [newClient, setNewClient] = useState<Record<string, string>>({
|
||||||
|
prenom: '',
|
||||||
|
nom: '',
|
||||||
|
telephone: '',
|
||||||
|
email: '',
|
||||||
|
adresse: '',
|
||||||
|
ville: '',
|
||||||
|
codePostal: '',
|
||||||
|
})
|
||||||
|
const touchStart = useRef<{ x: number; y: number; time: number } | null>(null)
|
||||||
|
const activeClientInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadClients()
|
||||||
|
loadProducts()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createClientOpen) {
|
||||||
|
setKeyboardOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement
|
||||||
|
const viewport = window.visualViewport
|
||||||
|
const updateViewport = () => {
|
||||||
|
const height = viewport?.height || window.innerHeight
|
||||||
|
const offsetTop = viewport?.offsetTop || 0
|
||||||
|
const keyboardHeight = Math.max(0, window.innerHeight - height - offsetTop)
|
||||||
|
root.style.setProperty('--seller-wizard-vh', `${height}px`)
|
||||||
|
root.style.setProperty('--seller-wizard-keyboard', `${keyboardHeight}px`)
|
||||||
|
setKeyboardOpen(keyboardHeight > 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateViewport()
|
||||||
|
viewport?.addEventListener('resize', updateViewport)
|
||||||
|
viewport?.addEventListener('scroll', updateViewport)
|
||||||
|
window.addEventListener('orientationchange', updateViewport)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
viewport?.removeEventListener('resize', updateViewport)
|
||||||
|
viewport?.removeEventListener('scroll', updateViewport)
|
||||||
|
window.removeEventListener('orientationchange', updateViewport)
|
||||||
|
root.style.removeProperty('--seller-wizard-vh')
|
||||||
|
root.style.removeProperty('--seller-wizard-keyboard')
|
||||||
|
}
|
||||||
|
}, [createClientOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createClientOpen) return
|
||||||
|
focusActiveClientInput()
|
||||||
|
}, [clientFieldIndex, createClientOpen])
|
||||||
|
|
||||||
|
const filteredClients = useMemo(() => {
|
||||||
|
const query = clientSearch.toLowerCase().trim()
|
||||||
|
if (!query) return clients.slice(0, 20)
|
||||||
|
return clients
|
||||||
|
.filter((client) =>
|
||||||
|
`${client.prenom} ${client.nom} ${client.telephone} ${client.email || ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)
|
||||||
|
)
|
||||||
|
.slice(0, 30)
|
||||||
|
}, [clients, clientSearch])
|
||||||
|
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
const query = productSearch.toLowerCase().trim()
|
||||||
|
return products
|
||||||
|
.filter((product) => {
|
||||||
|
const text = `${product.reference} ${product.designation} ${product.categorie} ${product.marque || ''}`.toLowerCase()
|
||||||
|
return !query || text.includes(query)
|
||||||
|
})
|
||||||
|
.slice(0, 24)
|
||||||
|
}, [products, productSearch])
|
||||||
|
|
||||||
|
const totalProducts = selectedProducts.reduce(
|
||||||
|
(sum, item) => sum + item.prixUnitaireTTC * item.quantite,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const total =
|
||||||
|
serviceType === 'REPAIR'
|
||||||
|
? repairPrice + additionalCharges
|
||||||
|
: totalProducts + additionalCharges
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
const response = await fetch('/api/clients')
|
||||||
|
if (response.ok) setClients(await response.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProducts() {
|
||||||
|
const response = await fetch('/api/pos/products')
|
||||||
|
if (response.ok) setProducts(await response.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseClient(client: Client) {
|
||||||
|
setSelectedClient(client)
|
||||||
|
setError('')
|
||||||
|
setStep('service')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProduct(product: Product) {
|
||||||
|
setSelectedProducts((current) => {
|
||||||
|
const existing = current.find((item) => item.produitId === product.id)
|
||||||
|
if (existing) {
|
||||||
|
return current.map((item) =>
|
||||||
|
item.produitId === product.id
|
||||||
|
? { ...item, quantite: Math.min(product.stock, item.quantite + 1) }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
produitId: product.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireTTC: product.prixVenteTTC,
|
||||||
|
label: `${product.designation} (${product.reference})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProduct(productId: string) {
|
||||||
|
setSelectedProducts((current) => current.filter((item) => item.produitId !== productId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextClientField() {
|
||||||
|
const field = clientFields[clientFieldIndex]
|
||||||
|
if (field.required && !newClient[field.key]?.trim()) {
|
||||||
|
setError(`${field.label} est obligatoire`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
if (clientFieldIndex < clientFields.length - 1) {
|
||||||
|
setClientFieldIndex((index) => index + 1)
|
||||||
|
focusActiveClientInput()
|
||||||
|
} else {
|
||||||
|
createClient()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusActiveClientInput() {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
activeClientInputRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/clients', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newClient),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Creation client impossible')
|
||||||
|
|
||||||
|
setClients((current) => [data, ...current])
|
||||||
|
setSelectedClient(data)
|
||||||
|
setCreateClientOpen(false)
|
||||||
|
setClientFieldIndex(0)
|
||||||
|
setNewClient({
|
||||||
|
prenom: '',
|
||||||
|
nom: '',
|
||||||
|
telephone: '',
|
||||||
|
email: '',
|
||||||
|
adresse: '',
|
||||||
|
ville: '',
|
||||||
|
codePostal: '',
|
||||||
|
})
|
||||||
|
setStep('service')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSale() {
|
||||||
|
if (!selectedClient) {
|
||||||
|
setError('Selectionnez un client')
|
||||||
|
setStep('client')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceType === 'COMBO' && selectedProducts.length === 0) {
|
||||||
|
setError('Ajoutez au moins une monture ou un verre')
|
||||||
|
setStep('details')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceType === 'REPAIR' && (!repairType.trim() || !repairDescription.trim())) {
|
||||||
|
setError('Completez le type et la description de reparation')
|
||||||
|
setStep('details')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/wizard-sale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId: selectedClient.id,
|
||||||
|
serviceType,
|
||||||
|
products: selectedProducts,
|
||||||
|
details: comboDetails,
|
||||||
|
repairType,
|
||||||
|
repairDescription,
|
||||||
|
repairPrice,
|
||||||
|
additionalCharges,
|
||||||
|
paymentMode,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(data.error || 'Vente impossible')
|
||||||
|
|
||||||
|
setSale(data)
|
||||||
|
setStep('final')
|
||||||
|
loadProducts()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWizard() {
|
||||||
|
setStep('client')
|
||||||
|
setSelectedClient(null)
|
||||||
|
setServiceType(null)
|
||||||
|
setRepairType('')
|
||||||
|
setRepairDescription('')
|
||||||
|
setRepairPrice(0)
|
||||||
|
setComboDetails('')
|
||||||
|
setSelectedProducts([])
|
||||||
|
setAdditionalCharges(0)
|
||||||
|
setPaymentMode('ESPECES')
|
||||||
|
setSale(null)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReceipt() {
|
||||||
|
const receipt = document.getElementById('seller-wizard-receipt')?.innerHTML
|
||||||
|
if (!receipt) return
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank', 'width=420,height=680')
|
||||||
|
if (!printWindow) return
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Recu ${sale?.numero || ''}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; padding: 16px; color: #111; }
|
||||||
|
.receipt { max-width: 360px; margin: 0 auto; }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 12px; margin: 8px 0; }
|
||||||
|
.total { font-size: 20px; font-weight: 700; border-top: 1px solid #111; padding-top: 12px; }
|
||||||
|
.muted { color: #555; font-size: 12px; }
|
||||||
|
h1, h2, p { margin: 4px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>${receipt}<script>window.print(); window.close();</script></body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
printWindow.document.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterFullscreen() {
|
||||||
|
const target = document.documentElement
|
||||||
|
if (!document.fullscreenElement && target.requestFullscreen) {
|
||||||
|
await target.requestFullscreen().catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(event: React.TouchEvent) {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
touchStart.current = {
|
||||||
|
x: touch.clientX,
|
||||||
|
y: touch.clientY,
|
||||||
|
time: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(event: React.TouchEvent) {
|
||||||
|
const start = touchStart.current
|
||||||
|
const touch = event.changedTouches[0]
|
||||||
|
if (!start || !touch) return
|
||||||
|
|
||||||
|
const diffX = touch.clientX - start.x
|
||||||
|
const diffY = touch.clientY - start.y
|
||||||
|
const elapsed = Date.now() - start.time
|
||||||
|
const isHorizontal = Math.abs(diffX) > 56 && Math.abs(diffX) > Math.abs(diffY) * 1.4
|
||||||
|
|
||||||
|
if (isHorizontal && elapsed < 800) {
|
||||||
|
if (diffX > 0) {
|
||||||
|
nextClientField()
|
||||||
|
} else {
|
||||||
|
setClientFieldIndex((index) => Math.max(0, index - 1))
|
||||||
|
focusActiveClientInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStart.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentField = clientFields[clientFieldIndex]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-4 text-base">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="lg" onClick={enterFullscreen} className="h-12 gap-2">
|
||||||
|
<Expand className="h-5 w-5" />
|
||||||
|
Plein ecran
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{(['client', 'service', 'details', 'final'] as Step[]).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className={`h-3 rounded-full ${step === item ? 'bg-primary' : index < ['client', 'service', 'details', 'final'].indexOf(step) ? 'bg-emerald-500' : 'bg-muted'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-lg text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'client' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
Choisir le client
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
className="h-20 text-xl"
|
||||||
|
onClick={() => setCreateClientOpen(false)}
|
||||||
|
variant={!createClientOpen ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
Client existant
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-20 text-xl"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateClientOpen(true)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
variant={createClientOpen ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
Nouveau client
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!createClientOpen && (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={clientSearch}
|
||||||
|
onChange={(event) => setClientSearch(event.target.value)}
|
||||||
|
placeholder="Nom, telephone, email..."
|
||||||
|
className="h-16 pl-12 text-xl"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{filteredClients.map((client) => (
|
||||||
|
<Button
|
||||||
|
key={client.id}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto justify-start p-5 text-left"
|
||||||
|
onClick={() => chooseClient(client)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-semibold">
|
||||||
|
{client.prenom} {client.nom}
|
||||||
|
</div>
|
||||||
|
<div className="text-base text-muted-foreground">{client.telephone}</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createClientOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background p-4 [height:var(--seller-wizard-vh,100dvh)] sm:p-5"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||||
|
<Button variant="ghost" size="lg" onClick={() => setCreateClientOpen(false)}>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" onClick={enterFullscreen} className="h-12">
|
||||||
|
<Expand className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Badge variant="secondary" className="px-4 py-2 text-base">
|
||||||
|
{clientFieldIndex + 1}/{clientFields.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex min-h-0 flex-1 flex-col ${
|
||||||
|
keyboardOpen ? 'justify-start pt-2' : 'justify-center'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
|
<Label
|
||||||
|
htmlFor={currentField.key}
|
||||||
|
className={`${keyboardOpen ? 'text-2xl' : 'text-3xl'} font-bold`}
|
||||||
|
>
|
||||||
|
{currentField.label}
|
||||||
|
{currentField.required ? ' *' : ''}
|
||||||
|
</Label>
|
||||||
|
<div className="mt-4 grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||||
|
<Input
|
||||||
|
id={currentField.key}
|
||||||
|
ref={activeClientInputRef}
|
||||||
|
value={newClient[currentField.key] || ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setNewClient({ ...newClient, [currentField.key]: event.target.value })
|
||||||
|
}
|
||||||
|
inputMode={currentField.inputMode}
|
||||||
|
enterKeyHint={clientFieldIndex === clientFields.length - 1 ? 'done' : 'next'}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
nextClientField()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${
|
||||||
|
keyboardOpen ? 'h-16 text-2xl' : 'h-20 text-3xl'
|
||||||
|
} min-w-0`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={`${keyboardOpen ? 'h-16 px-5' : 'h-20 px-6'} text-xl`}
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={nextClientField}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{clientFieldIndex === clientFields.length - 1 ? 'Creer' : 'Suivant'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`mt-4 text-lg text-muted-foreground ${
|
||||||
|
keyboardOpen ? 'hidden sm:block' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Balayez vers la droite ou appuyez sur Suivant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid shrink-0 grid-cols-2 gap-3 ${
|
||||||
|
keyboardOpen ? 'pb-[max(env(safe-area-inset-bottom),0.25rem)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
|
||||||
|
disabled={clientFieldIndex === 0}
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setClientFieldIndex((index) => Math.max(0, index - 1))
|
||||||
|
focusActiveClientInput()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-6 w-6" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={nextClientField}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Swipe / Next
|
||||||
|
<ArrowRight className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'service' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl">Quel service ?</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="rounded-md bg-muted p-4 text-xl">
|
||||||
|
{selectedClient?.prenom} {selectedClient?.nom}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
className="h-32 flex-col text-2xl"
|
||||||
|
variant={serviceType === 'COMBO' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setServiceType('COMBO')
|
||||||
|
setStep('details')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Package className="h-9 w-9" />
|
||||||
|
Monture + verres
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-32 flex-col text-2xl"
|
||||||
|
variant={serviceType === 'REPAIR' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setServiceType('REPAIR')
|
||||||
|
setStep('details')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wrench className="h-9 w-9" />
|
||||||
|
Reparation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="h-14 text-lg" onClick={() => setStep('client')}>
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_320px]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl">
|
||||||
|
{serviceType === 'REPAIR' ? 'Details reparation' : 'Produits'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{serviceType === 'COMBO' ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={productSearch}
|
||||||
|
onChange={(event) => setProductSearch(event.target.value)}
|
||||||
|
placeholder="Chercher monture, verre, reference..."
|
||||||
|
className="h-16 text-xl"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{filteredProducts.map((product) => (
|
||||||
|
<Button
|
||||||
|
key={product.id}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto justify-between gap-3 p-4 text-left"
|
||||||
|
onClick={() => addProduct(product)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">{product.designation}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{product.reference} - Stock {product.stock}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold">{currency(product.prixVenteTTC)}</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={comboDetails}
|
||||||
|
onChange={(event) => setComboDetails(event.target.value)}
|
||||||
|
placeholder="Notes simples: correction, couleur, mesures..."
|
||||||
|
className="min-h-24 text-lg"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={repairType}
|
||||||
|
onChange={(event) => setRepairType(event.target.value)}
|
||||||
|
placeholder="Type: branche, vis, soudure..."
|
||||||
|
className="h-16 text-xl"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
value={repairDescription}
|
||||||
|
onChange={(event) => setRepairDescription(event.target.value)}
|
||||||
|
placeholder="Description claire de la reparation"
|
||||||
|
className="min-h-40 text-xl"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={repairPrice || ''}
|
||||||
|
onChange={(event) => setRepairPrice(Number(event.target.value))}
|
||||||
|
placeholder="Prix reparation"
|
||||||
|
className="h-16 text-xl"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Resume</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{selectedProducts.map((item) => (
|
||||||
|
<div key={item.produitId} className="rounded-md border p-3">
|
||||||
|
<div className="font-semibold">{item.label}</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span>Qté {item.quantite}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => removeProduct(item.produitId)}>
|
||||||
|
Retirer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-base">Frais supplementaires</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={additionalCharges || ''}
|
||||||
|
onChange={(event) => setAdditionalCharges(Number(event.target.value))}
|
||||||
|
className="mt-2 h-14 text-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-base">Paiement</Label>
|
||||||
|
<Select value={paymentMode} onValueChange={(value: PaymentMode) => setPaymentMode(value)}>
|
||||||
|
<SelectTrigger className="mt-2 h-14 w-full text-lg">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(paymentLabels).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted p-4">
|
||||||
|
<div className="text-muted-foreground">Total</div>
|
||||||
|
<div className="text-4xl font-bold">{currency(total)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button variant="outline" className="h-14 text-lg" onClick={() => setStep('service')}>
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
<Button className="h-14 text-lg" onClick={submitSale} disabled={loading}>
|
||||||
|
<Check className="h-5 w-5" />
|
||||||
|
Valider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'final' && sale && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||||
|
<FileText className="h-8 w-8" />
|
||||||
|
Recu pret
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div id="seller-wizard-receipt" className="receipt rounded-md border bg-white p-5 text-black">
|
||||||
|
<h1>OptiqueStock</h1>
|
||||||
|
<p className="muted">Recu {sale.numero}</p>
|
||||||
|
<p className="muted">{new Date(sale.date).toLocaleString('fr-FR')}</p>
|
||||||
|
{sale.client && (
|
||||||
|
<p>
|
||||||
|
Client: {sale.client.prenom} {sale.client.nom} - {sale.client.telephone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<hr />
|
||||||
|
{sale.lignes.map((line, index) => (
|
||||||
|
<div key={index} className="row">
|
||||||
|
<span>
|
||||||
|
{line.quantite} x {line.produit.designation}
|
||||||
|
</span>
|
||||||
|
<strong>{currency(line.montantTTC)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sale.notes && <p className="muted">{sale.notes}</p>}
|
||||||
|
<div className="row">
|
||||||
|
<span>HT</span>
|
||||||
|
<span>{currency(sale.montantHT)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<span>TVA</span>
|
||||||
|
<span>{currency(sale.montantTVA)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row total">
|
||||||
|
<span>Total TTC</span>
|
||||||
|
<span>{currency(sale.montantTTC)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Paiement: {paymentLabels[sale.paiements[0]?.mode || 'ESPECES']}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Button className="h-16 text-xl" onClick={printReceipt}>
|
||||||
|
<Printer className="h-6 w-6" />
|
||||||
|
Imprimer maintenant
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-16 text-xl" onClick={resetWizard}>
|
||||||
|
Nouvelle vente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/components/theme-toggle.tsx
Normal file
22
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
title={isDark ? 'Mode clair' : 'Mode sombre'}
|
||||||
|
aria-label={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/lib/auth-utils.ts
Normal file
20
src/lib/auth-utils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from './auth'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
return await getServerSession(authOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUserId(): Promise<string | null> {
|
||||||
|
const session = await getSession()
|
||||||
|
return (session?.user as any)?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
60
src/lib/auth.ts
Normal file
60
src/lib/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextAuthOptions } from 'next-auth'
|
||||||
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from './db'
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: 'credentials',
|
||||||
|
credentials: {
|
||||||
|
email: { label: 'Email', type: 'email' },
|
||||||
|
password: { label: 'Mot de passe', type: 'password' },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null
|
||||||
|
|
||||||
|
const employe = await db.employe.findUnique({
|
||||||
|
where: { email: credentials.email },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!employe || !employe.actif) return null
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(credentials.password, employe.motDePasse)
|
||||||
|
if (!isValid) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: employe.id,
|
||||||
|
email: employe.email,
|
||||||
|
name: `${employe.prenom} ${employe.nom}`,
|
||||||
|
role: employe.role,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.userId = user.id
|
||||||
|
token.role = (user as any).role
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
Object.assign(session.user, {
|
||||||
|
id: token.userId,
|
||||||
|
role: token.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/login',
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt',
|
||||||
|
},
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
}
|
||||||
30
src/proxy.ts
Normal file
30
src/proxy.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { getToken } from 'next-auth/jwt'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/login') ||
|
||||||
|
pathname.startsWith('/api/auth') ||
|
||||||
|
pathname.startsWith('/_next/static') ||
|
||||||
|
pathname.startsWith('/_next/image') ||
|
||||||
|
pathname === '/favicon.ico'
|
||||||
|
) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET })
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL('/login', request.url)
|
||||||
|
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user