Compare commits

...

9 Commits

Author SHA1 Message Date
3e2501721a Add Hermes API and MCP access 2026-05-31 21:35:50 +01:00
865c4a78ea Add demo data branch tools and tablet polish 2026-05-31 21:14:42 +01:00
86beb8a5dd Keep tablet keyboard open between wizard fields 2026-05-31 21:09:22 +01:00
e794ecceb6 Add tablet seller wizard and access controls 2026-05-31 20:59:58 +01:00
14de88945c Add cookies.txt and *.log to gitignore 2026-05-30 15:42:14 +01:00
b169e975bb Remove accidentally committed cookies.txt 2026-05-30 15:42:02 +01:00
661d053ea0 Fix login redirect - use server-side redirect instead of redirect:false
The signIn with redirect:false + router.push wasn't working
properly. Switched to callbackUrl approach where the server
handles the 302 redirect and cookie setting natively.
2026-05-30 15:41:59 +01:00
816c1c40c6 Add authentication system with next-auth (CredentialsProvider + JWT)
- Login page with email/password
- Auth middleware (proxy) protecting all routes
- Seed endpoint for admin user creation (admin@optiquestock.com / admin123)
- Session provider wrapping root layout
- User info + logout button in header
- Updated POS sales route to track authenticated user
2026-05-30 15:35:40 +01:00
d23f2ab53e Enhanced README with project-specific documentation and added SQLite database 2026-05-30 15:01:40 +01:00
38 changed files with 3780 additions and 369 deletions

2
.gitignore vendored
View File

@@ -49,3 +49,5 @@ prompt
server.log server.log
# Skills directory # Skills directory
/skills/ /skills/
cookies.txt
*.log

188
README.md
View File

@@ -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) 🚀

View File

@@ -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=="],

View File

@@ -0,0 +1,50 @@
# OptiqueStock Hermes MCP
This MCP server gives a Hermes agent safe, token-scoped access to OptiqueStock.
## Environment
Set the same key on the Next.js app and the MCP server:
```bash
HERMES_API_KEY=change-me
OPTICZ_API_BASE=http://192.168.1.30:3000
```
In local development only, the fallback key is:
```text
hermes-demo-key-change-me
```
## Run
```bash
node mini-services/hermes-mcp/server.mjs
```
## MCP Tools
- `opticz_status`
- `opticz_summary`
- `opticz_search_clients`
- `opticz_search_products`
- `opticz_create_client`
- `opticz_create_repair_sale`
## Direct REST API
All endpoints require:
```http
Authorization: Bearer <HERMES_API_KEY>
```
Available endpoints:
- `GET /api/hermes/status`
- `GET /api/hermes/summary`
- `GET /api/hermes/clients?q=amina`
- `POST /api/hermes/clients`
- `GET /api/hermes/products?q=monture`
- `POST /api/hermes/sales/repair`

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env node
const apiBase = (process.env.OPTICZ_API_BASE || 'http://127.0.0.1:3000').replace(/\/$/, '')
const apiKey = process.env.HERMES_API_KEY || 'hermes-demo-key-change-me'
const tools = [
{
name: 'opticz_status',
description: 'Check OptiqueStock Hermes API health.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'opticz_summary',
description: 'Get store counts, low stock products, pending workshop count, and recent sales.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'opticz_search_clients',
description: 'Search OptiqueStock clients by name, phone, or email.',
inputSchema: {
type: 'object',
properties: {
q: { type: 'string', description: 'Search query.' },
limit: { type: 'number', description: 'Maximum results, default 20, max 50.' },
},
},
},
{
name: 'opticz_search_products',
description: 'Search active products by reference, designation, brand, or category.',
inputSchema: {
type: 'object',
properties: {
q: { type: 'string', description: 'Search query.' },
category: { type: 'string', description: 'Optional category filter, e.g. MONTURE or VERRE.' },
inStockOnly: { type: 'boolean', description: 'Only include products with stock. Defaults true.' },
limit: { type: 'number', description: 'Maximum results, default 25, max 75.' },
},
},
},
{
name: 'opticz_create_client',
description: 'Create a client, or return the existing client when the phone number already exists.',
inputSchema: {
type: 'object',
required: ['nom', 'prenom', 'telephone'],
properties: {
nom: { type: 'string' },
prenom: { type: 'string' },
telephone: { type: 'string' },
email: { type: 'string' },
adresse: { type: 'string' },
ville: { type: 'string' },
codePostal: { type: 'string' },
notes: { type: 'string' },
},
},
},
{
name: 'opticz_create_repair_sale',
description: 'Create a paid repair sale for an existing client.',
inputSchema: {
type: 'object',
required: ['clientId', 'repairType', 'repairDescription', 'repairPrice'],
properties: {
clientId: { type: 'string' },
repairType: { type: 'string' },
repairDescription: { type: 'string' },
repairPrice: { type: 'number' },
additionalCharges: { type: 'number' },
paymentMode: {
type: 'string',
enum: ['ESPECES', 'CARTE', 'CHEQUE', 'VIREMENT', 'BON_CAISSE'],
},
},
},
},
]
async function api(path, options = {}) {
const response = await fetch(`${apiBase}${path}`, {
...options,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...(options.headers || {}),
},
})
const text = await response.text()
const data = text ? JSON.parse(text) : null
if (!response.ok) {
throw new Error(data?.error || `HTTP ${response.status}`)
}
return data
}
function query(params) {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params || {})) {
if (value !== undefined && value !== null && value !== '') {
search.set(key, String(value))
}
}
const value = search.toString()
return value ? `?${value}` : ''
}
async function callTool(name, args) {
switch (name) {
case 'opticz_status':
return api('/api/hermes/status')
case 'opticz_summary':
return api('/api/hermes/summary')
case 'opticz_search_clients':
return api(`/api/hermes/clients${query(args)}`)
case 'opticz_search_products':
return api(`/api/hermes/products${query(args)}`)
case 'opticz_create_client':
return api('/api/hermes/clients', {
method: 'POST',
body: JSON.stringify(args || {}),
})
case 'opticz_create_repair_sale':
return api('/api/hermes/sales/repair', {
method: 'POST',
body: JSON.stringify(args || {}),
})
default:
throw new Error(`Unknown tool: ${name}`)
}
}
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`)
}
async function handle(message) {
const { id, method, params } = message
if (method === 'initialize') {
send({
jsonrpc: '2.0',
id,
result: {
protocolVersion: params?.protocolVersion || '2024-11-05',
capabilities: {
tools: {},
},
serverInfo: {
name: 'opticz-hermes-mcp',
version: '0.1.0',
},
},
})
return
}
if (method === 'notifications/initialized') return
if (method === 'tools/list') {
send({
jsonrpc: '2.0',
id,
result: { tools },
})
return
}
if (method === 'tools/call') {
try {
const result = await callTool(params?.name, params?.arguments || {})
send({
jsonrpc: '2.0',
id,
result: {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
},
})
} catch (error) {
send({
jsonrpc: '2.0',
id,
result: {
isError: true,
content: [
{
type: 'text',
text: error instanceof Error ? error.message : 'Unknown error',
},
],
},
})
}
return
}
send({
jsonrpc: '2.0',
id,
error: {
code: -32601,
message: `Unsupported method: ${method}`,
},
})
}
let buffer = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
buffer += chunk
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) continue
try {
handle(JSON.parse(line))
} catch (error) {
send({
jsonrpc: '2.0',
id: null,
error: {
code: -32700,
message: error instanceof Error ? error.message : 'Parse error',
},
})
}
}
})

View File

@@ -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,

View File

@@ -10,7 +10,8 @@
"db:push": "prisma db push", "db:push": "prisma db push",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset" "db:reset": "prisma migrate reset",
"hermes:mcp": "node mini-services/hermes-mcp/server.mjs"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -50,6 +51,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 +86,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

Binary file not shown.

19
public/manifest.json Normal file
View 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"
}
]
}

View 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 }
)
}
}

View 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 }

View 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 })
}
}

View File

@@ -0,0 +1,272 @@
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { ModePaiement, StatutAtelier, StatutVente } from '@prisma/client'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth-utils'
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
}
function htFromTtc(ttc: number, tva = 20) {
return ttc / (1 + tva / 100)
}
export async function POST() {
const authError = await requireAdmin()
if (authError) return authError
try {
const suppliers = await Promise.all([
db.fournisseur.upsert({
where: { id: 'demo-fournisseur-lumioptic' },
update: {
nom: 'LumiOptic Distribution',
contact: 'Nadia Benali',
email: 'contact@demo-lumioptic.local',
telephone: '0522000001',
ville: 'Casablanca',
actif: true,
},
create: {
id: 'demo-fournisseur-lumioptic',
nom: 'LumiOptic Distribution',
contact: 'Nadia Benali',
email: 'contact@demo-lumioptic.local',
telephone: '0522000001',
ville: 'Casablanca',
},
}),
db.fournisseur.upsert({
where: { id: 'demo-fournisseur-clairverre' },
update: {
nom: 'ClairVerre Pro',
contact: 'Yassine El Amrani',
email: 'contact@demo-clairverre.local',
telephone: '0522000002',
ville: 'Rabat',
actif: true,
},
create: {
id: 'demo-fournisseur-clairverre',
nom: 'ClairVerre Pro',
contact: 'Yassine El Amrani',
email: 'contact@demo-clairverre.local',
telephone: '0522000002',
ville: 'Rabat',
},
}),
])
const productsData = [
['DEMO-MNT-001', 'Monture acetate noir Atlas', 'MONTURE', 42, 129, 12, 4, 'Atlas', 'COMPLET', suppliers[0].id],
['DEMO-MNT-002', 'Monture metal fine Sofia', 'MONTURE', 38, 115, 9, 3, 'Sofia', 'COMPLET', suppliers[0].id],
['DEMO-MNT-003', 'Monture enfant Flex Kids', 'MONTURE', 25, 79, 14, 5, 'Flex Kids', 'NATUREL', suppliers[0].id],
['DEMO-VR-001', 'Verres simple vision anti-reflet', 'VERRE', 18, 59, 40, 10, null, null, suppliers[1].id],
['DEMO-VR-002', 'Verres progressifs confort', 'VERRE', 58, 189, 18, 6, null, null, suppliers[1].id],
['DEMO-VR-003', 'Verres anti-lumiere bleue', 'VERRE', 24, 89, 22, 8, null, null, suppliers[1].id],
['DEMO-LEN-001', 'Lentilles mensuelles confort', 'LENTILLE', 9, 29, 30, 8, 'ClearDay', null, suppliers[1].id],
['DEMO-ACC-001', 'Kit nettoyage premium', 'ACCESSOIRE', 3, 12, 50, 12, null, null, suppliers[0].id],
['DEMO-ACC-002', 'Etui rigide signature', 'ACCESSOIRE', 5, 18, 35, 10, null, null, suppliers[0].id],
['DEMO-ACC-003', 'Cordon lunettes sport', 'ACCESSOIRE', 2, 9, 5, 10, null, null, suppliers[0].id],
] as const
const products = await Promise.all(
productsData.map(([reference, designation, categorie, prixAchatHT, prixVenteTTC, stock, stockMin, marque, typeMonture, fournisseurId]) =>
db.produit.upsert({
where: { reference },
update: {
designation,
categorie,
prixAchatHT,
prixVenteTTC,
stock,
stockMin,
marque,
typeMonture: typeMonture as any,
fournisseurId,
actif: true,
},
create: {
reference,
designation,
categorie,
prixAchatHT,
prixVenteTTC,
tva: 20,
stock,
stockMin,
marque,
typeMonture: typeMonture as any,
fournisseurId,
actif: true,
},
})
)
)
const clientsData = [
['demo-client-1', 'Amina', 'Rachid', '0600000101', 'amina.rachid@demo.local', 'Casablanca'],
['demo-client-2', 'Karim', 'Bennani', '0600000102', 'karim.bennani@demo.local', 'Rabat'],
['demo-client-3', 'Sara', 'El Fassi', '0600000103', 'sara.elfassi@demo.local', 'Marrakech'],
['demo-client-4', 'Youssef', 'Idrissi', '0600000104', null, 'Tanger'],
['demo-client-5', 'Leila', 'Mansouri', '0600000105', 'leila.mansouri@demo.local', 'Fes'],
] as const
const clients = await Promise.all(
clientsData.map(([id, prenom, nom, telephone, email, ville]) =>
db.client.upsert({
where: { telephone },
update: {
prenom,
nom,
email,
ville,
},
create: {
id,
prenom,
nom,
telephone,
email,
ville,
},
})
)
)
const password = await bcrypt.hash('demo123', 12)
const employees = await Promise.all([
db.employe.upsert({
where: { email: 'vendeur.demo@optiquestock.local' },
update: {
nom: 'Demo',
prenom: 'Vendeur',
role: 'VENDEUR',
actif: true,
},
create: {
email: 'vendeur.demo@optiquestock.local',
nom: 'Demo',
prenom: 'Vendeur',
role: 'VENDEUR',
actif: true,
motDePasse: password,
},
}),
db.employe.upsert({
where: { email: 'responsable.demo@optiquestock.local' },
update: {
nom: 'Demo',
prenom: 'Responsable',
role: 'RESPONSABLE',
actif: true,
},
create: {
email: 'responsable.demo@optiquestock.local',
nom: 'Demo',
prenom: 'Responsable',
role: 'RESPONSABLE',
actif: true,
motDePasse: password,
},
}),
])
const saleSpecs = [
{
numero: 'DEMO-VENTE-001',
client: clients[0],
statutAtelier: StatutAtelier.EN_COURS,
items: [products[0], products[3], products[7]],
payment: ModePaiement.CARTE,
},
{
numero: 'DEMO-VENTE-002',
client: clients[1],
statutAtelier: StatutAtelier.PRET,
items: [products[1], products[4]],
payment: ModePaiement.ESPECES,
},
{
numero: 'DEMO-VENTE-003',
client: clients[2],
statutAtelier: StatutAtelier.EN_ATTENTE,
items: [products[2], products[5]],
payment: ModePaiement.CHEQUE,
},
]
let salesCreated = 0
for (const spec of saleSpecs) {
const existingSale = await db.vente.findUnique({ where: { numero: spec.numero } })
if (existingSale) continue
const montantTTC = spec.items.reduce((sum, product) => sum + product.prixVenteTTC, 0)
const montantHT = spec.items.reduce((sum, product) => sum + htFromTtc(product.prixVenteTTC), 0)
await db.vente.create({
data: {
numero: spec.numero,
clientId: spec.client.id,
employeId: employees[0].id,
statut: StatutVente.PAYEE,
statutAtelier: spec.statutAtelier,
montantHT,
montantTVA: montantTTC - montantHT,
montantTTC,
notes: 'Vente demo generee automatiquement',
dateAtelier: new Date(),
lignes: {
create: spec.items.map((product) => ({
produitId: product.id,
quantite: 1,
prixUnitaireHT: htFromTtc(product.prixVenteTTC),
prixUnitaireTTC: product.prixVenteTTC,
remise: 0,
montantHT: htFromTtc(product.prixVenteTTC),
montantTTC: product.prixVenteTTC,
})),
},
paiements: {
create: {
mode: spec.payment,
montant: montantTTC,
employeId: employees[0].id,
reference: spec.numero,
},
},
},
})
salesCreated += 1
}
return NextResponse.json({
message: 'Demo data ready',
clients: clients.length,
products: products.length,
suppliers: suppliers.length,
employees: employees.length,
salesCreated,
demoLogins: [
{ email: 'vendeur.demo@optiquestock.local', password: 'demo123' },
{ email: 'responsable.demo@optiquestock.local', password: 'demo123' },
],
})
} catch (error) {
console.error('Demo seed error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to seed demo data' },
{ status: 500 }
)
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const searchParams = request.nextUrl.searchParams
const q = (searchParams.get('q') || '').trim()
const limit = Math.min(Number(searchParams.get('limit') || 20), 50)
const clients = await db.client.findMany({
where: q
? {
OR: [
{ nom: { contains: q } },
{ prenom: { contains: q } },
{ telephone: { contains: q } },
{ email: { contains: q } },
],
}
: undefined,
orderBy: [{ nom: 'asc' }, { prenom: 'asc' }],
take: limit,
select: {
id: true,
nom: true,
prenom: true,
email: true,
telephone: true,
ville: true,
codePostal: true,
createdAt: true,
},
})
return NextResponse.json({ clients })
}
export async function POST(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const body = await request.json()
const nom = String(body.nom || '').trim()
const prenom = String(body.prenom || '').trim()
const telephone = String(body.telephone || '').trim()
if (!nom || !prenom || !telephone) {
return NextResponse.json(
{ error: 'nom, prenom and telephone are required' },
{ status: 400 }
)
}
const existingClient = await db.client.findUnique({ where: { telephone } })
if (existingClient) {
return NextResponse.json({ client: existingClient, existed: true })
}
const client = await db.client.create({
data: {
nom,
prenom,
telephone,
email: body.email || null,
adresse: body.adresse || null,
ville: body.ville || null,
codePostal: body.codePostal || null,
notes: body.notes || null,
},
})
return NextResponse.json({ client, existed: false }, { status: 201 })
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const searchParams = request.nextUrl.searchParams
const q = (searchParams.get('q') || '').trim()
const category = (searchParams.get('category') || '').trim()
const inStockOnly = searchParams.get('inStockOnly') !== 'false'
const limit = Math.min(Number(searchParams.get('limit') || 25), 75)
const products = await db.produit.findMany({
where: {
actif: true,
...(inStockOnly ? { stock: { gt: 0 } } : {}),
...(category ? { categorie: category } : {}),
...(q
? {
OR: [
{ reference: { contains: q } },
{ designation: { contains: q } },
{ marque: { contains: q } },
{ categorie: { contains: q } },
],
}
: {}),
},
orderBy: [{ categorie: 'asc' }, { designation: 'asc' }],
take: limit,
select: {
id: true,
reference: true,
designation: true,
categorie: true,
marque: true,
prixVenteTTC: true,
tva: true,
stock: true,
stockMin: true,
emplacement: true,
},
})
return NextResponse.json({ products })
}

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { ModePaiement } from '@prisma/client'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
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 `H${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}`
}
function toHt(ttc: number) {
return ttc / (1 + SERVICE_TVA / 100)
}
async function getRepairProduct() {
return db.produit.upsert({
where: { reference: 'HERMES_SERVICE_REPAIR' },
update: {
designation: 'Hermes service reparation',
actif: true,
stock: { increment: 1 },
},
create: {
reference: 'HERMES_SERVICE_REPAIR',
designation: 'Hermes service reparation',
categorie: 'SERVICE',
prixAchatHT: 0,
prixVenteTTC: 0,
tva: SERVICE_TVA,
stock: 999999,
stockMin: 0,
actif: true,
},
})
}
export async function POST(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const body = await request.json()
const clientId = String(body.clientId || '').trim()
const repairType = String(body.repairType || '').trim()
const repairDescription = String(body.repairDescription || '').trim()
const repairPrice = Math.max(0, Number(body.repairPrice || 0))
const additionalCharges = Math.max(0, Number(body.additionalCharges || 0))
const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement
if (!clientId || !repairType || !repairDescription) {
return NextResponse.json(
{ error: 'clientId, repairType and repairDescription are required' },
{ status: 400 }
)
}
const client = await db.client.findUnique({ where: { id: clientId } })
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
const repairProduct = await getRepairProduct()
const totalTTC = repairPrice + additionalCharges
const totalHT = toHt(totalTTC)
const numero = await generateSaleNumber()
const sale = await db.vente.create({
data: {
numero,
clientId,
statut: 'PAYEE',
montantHT: totalHT,
montantTVA: totalTTC - totalHT,
montantTTC: totalTTC,
notes: [
`Hermes repair: ${repairType}`,
`Description: ${repairDescription}`,
additionalCharges > 0 ? `Additional charges: ${additionalCharges.toFixed(2)}` : '',
]
.filter(Boolean)
.join('\n'),
lignes: {
create: {
produitId: repairProduct.id,
quantite: 1,
prixUnitaireHT: totalHT,
prixUnitaireTTC: totalTTC,
remise: 0,
montantHT: totalHT,
montantTTC: totalTTC,
},
},
paiements: {
create: {
mode: paymentMode,
montant: totalTTC,
reference: 'HERMES',
},
},
},
include: {
client: true,
lignes: {
include: {
produit: true,
},
},
paiements: true,
},
})
return NextResponse.json({ sale }, { status: 201 })
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
await db.$queryRaw`SELECT 1`
return NextResponse.json({
ok: true,
service: 'OptiqueStock Hermes API',
time: new Date().toISOString(),
})
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { requireHermesAccess } from '@/lib/hermes-auth'
export async function GET(request: NextRequest) {
const authError = requireHermesAccess(request)
if (authError) return authError
const [clients, activeProducts, lowStock, pendingWorkshop, sales] = await Promise.all([
db.client.count(),
db.produit.count({ where: { actif: true } }),
db.produit.findMany({
where: { actif: true },
orderBy: { stock: 'asc' },
take: 10,
select: {
id: true,
reference: true,
designation: true,
categorie: true,
stock: true,
stockMin: true,
prixVenteTTC: true,
},
}),
db.vente.count({
where: {
statutAtelier: {
in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'],
},
},
}),
db.vente.findMany({
orderBy: { date: 'desc' },
take: 8,
include: {
client: {
select: {
id: true,
nom: true,
prenom: true,
telephone: true,
},
},
},
}),
])
return NextResponse.json({
counts: {
clients,
activeProducts,
pendingWorkshop,
},
lowStock: lowStock.filter((item) => item.stock <= item.stockMin),
recentSales: sales.map((sale) => ({
id: sale.id,
numero: sale.numero,
date: sale.date,
statut: sale.statut,
statutAtelier: sale.statutAtelier,
montantTTC: sale.montantTTC,
client: sale.client,
})),
})
}

View File

@@ -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
} }
}) })
} }

View 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 })
}
}

View File

@@ -1,7 +1,10 @@
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";
import { CurrencyProvider } from "@/components/currency-provider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -14,38 +17,42 @@ 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`}
> >
<ThemeProvider>
<CurrencyProvider>
<SessionProvider>
{children} {children}
</SessionProvider>
</CurrencyProvider>
</ThemeProvider>
<Toaster /> <Toaster />
</body> </body>
</html> </html>

129
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,129 @@
'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)
await requestFullscreen()
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>
)
}
async function requestFullscreen() {
if (document.fullscreenElement || !document.documentElement.requestFullscreen) return
await document.documentElement.requestFullscreen().catch(() => undefined)
}
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>
)
}

View File

@@ -1,19 +1,27 @@
'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,
Database,
Eye, Eye,
LayoutDashboard, LayoutDashboard,
Wrench Loader2,
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 +30,25 @@ 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'
import { CurrencySelect } from '@/components/currency-select'
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 +57,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 +84,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 +109,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 +118,151 @@ 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 [demoGenerating, setDemoGenerating] = useState(false)
const [demoMessage, setDemoMessage] = useState('')
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])
async function generateDemoData() {
setDemoGenerating(true)
setDemoMessage('')
try {
const response = await fetch('/api/demo/seed', { method: 'POST' })
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Generation demo impossible')
}
setDemoMessage(
`Demo prete: ${data.clients} clients, ${data.products} produits, ${data.salesCreated} nouvelles ventes.`
)
} catch (error) {
setDemoMessage(error instanceof Error ? error.message : 'Erreur demo inconnue')
} finally {
setDemoGenerating(false)
}
}
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"> {currentRole === 'ADMIN' && (
{modules.map((module) => ( <div className="mx-auto flex max-w-2xl flex-col items-center gap-3 rounded-md border bg-card p-4 text-center">
<Button
variant="outline"
size="lg"
onClick={generateDemoData}
disabled={demoGenerating}
className="h-12 gap-2"
>
{demoGenerating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Database className="h-5 w-5" />
)}
Generer les donnees demo
</Button>
{demoMessage && <p className="text-sm text-muted-foreground">{demoMessage}</p>}
</div>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{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 +270,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 +279,140 @@ 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>
) )
} }
if (currentModule === 'UTILISATEURS') {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> {moduleHeader}
<Button <EmployeeManagement />
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>
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className={`p-6 rounded-full ${moduleInfo?.color} bg-opacity-10 mb-4`}>
<Eye className="h-16 w-16 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-700 mb-2">
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.
Veuillez revenir ultérieurement.
</p>
</CardContent>
</Card>
</div> </div>
) )
} }
if (currentModule === 'IA') {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100"> <div className="space-y-6">
<header className="bg-white border-b shadow-sm"> {moduleHeader}
<AIAssistant />
</div>
)
}
if (currentModule === 'VENDEUR_WIZARD') {
return (
<div className="space-y-6">
{moduleHeader}
<SellerWizard />
</div>
)
}
return null
}
return (
<div className="min-h-screen bg-background text-foreground">
<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 <CurrencySelect />
</Badge> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium"> <User className="h-4 w-4" />
OP <span className="hidden sm:inline">{session?.user?.name}</span>
<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>

View 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>
)
}

View File

@@ -241,35 +241,6 @@ export default function AtelierModule() {
} }
} }
// Seed sample data
const seedSampleData = async () => {
try {
const response = await fetch('/api/atelier/seed?XTransformPort=3000', {
method: 'POST'
})
if (response.ok) {
toast({
title: 'Données ajoutées',
description: 'Les données de test ont été créées avec succès'
})
loadWorkOrders()
} else {
const error = await response.json()
toast({
title: 'Erreur',
description: error.error || 'Impossible de créer les données de test',
variant: 'destructive'
})
}
} catch (error) {
console.error('Error seeding data:', error)
toast({
title: 'Erreur',
description: 'Impossible de créer les données de test',
variant: 'destructive'
})
}
}
useEffect(() => { useEffect(() => {
loadWorkOrders() loadWorkOrders()
@@ -480,12 +451,6 @@ export default function AtelierModule() {
: `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"` : `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"`
} }
</p> </p>
{statusFilter === 'ALL' && workOrders.length === 0 && (
<Button onClick={seedSampleData} variant="outline">
<Wrench className="h-4 w-4 mr-2" />
Ajouter des données de test
</Button>
)}
</div> </div>
) : ( ) : (
<ScrollArea className="max-h-[600px]"> <ScrollArea className="max-h-[600px]">

View 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>
}

View File

@@ -0,0 +1,57 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
export type CurrencyCode = 'MAD' | 'EUR' | 'USD'
const currencyLocales: Record<CurrencyCode, string> = {
MAD: 'fr-MA',
EUR: 'fr-FR',
USD: 'en-US',
}
interface CurrencyContextValue {
currency: CurrencyCode
setCurrency: (currency: CurrencyCode) => void
formatCurrency: (value: number) => string
}
const CurrencyContext = createContext<CurrencyContextValue | null>(null)
export function CurrencyProvider({ children }: { children: React.ReactNode }) {
const [currency, setCurrencyState] = useState<CurrencyCode>('MAD')
useEffect(() => {
const stored = window.localStorage.getItem('optiquestock-currency') as CurrencyCode | null
if (stored === 'MAD' || stored === 'EUR' || stored === 'USD') {
setCurrencyState(stored)
}
}, [])
const value = useMemo<CurrencyContextValue>(() => {
return {
currency,
setCurrency(nextCurrency) {
setCurrencyState(nextCurrency)
window.localStorage.setItem('optiquestock-currency', nextCurrency)
},
formatCurrency(amount) {
return new Intl.NumberFormat(currencyLocales[currency], {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(amount)
},
}
}, [currency])
return <CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>
}
export function useCurrency() {
const context = useContext(CurrencyContext)
if (!context) {
throw new Error('useCurrency must be used within CurrencyProvider')
}
return context
}

View File

@@ -0,0 +1,25 @@
'use client'
import { BadgeDollarSign } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CurrencyCode, useCurrency } from '@/components/currency-provider'
export function CurrencySelect() {
const { currency, setCurrency } = useCurrency()
return (
<div className="flex items-center gap-2">
<BadgeDollarSign className="h-4 w-4 text-muted-foreground" />
<Select value={currency} onValueChange={(value: CurrencyCode) => setCurrency(value)}>
<SelectTrigger className="h-9 w-[92px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MAD">MAD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
</SelectContent>
</Select>
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,898 @@
'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'
import { useCurrency } from '@/components/currency-provider'
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',
}
export function SellerWizard() {
const { formatCurrency } = useCurrency()
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)
holdKeyboardOpen()
} else {
createClient()
}
}
function focusActiveClientInput() {
activeClientInputRef.current?.focus({ preventScroll: true })
}
function holdKeyboardOpen() {
focusActiveClientInput()
window.requestAnimationFrame(focusActiveClientInput)
window.setTimeout(focusActiveClientInput, 40)
window.setTimeout(focusActiveClientInput, 120)
}
function keepTouchOnInput(event: React.MouseEvent | React.PointerEvent | React.TouchEvent) {
event.preventDefault()
holdKeyboardOpen()
}
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))
holdKeyboardOpen()
}
}
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()
}
}}
onBlur={() => {
if (createClientOpen && !loading) {
holdKeyboardOpen()
}
}}
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`}
type="button"
tabIndex={-1}
onMouseDown={keepTouchOnInput}
onPointerDown={keepTouchOnInput}
onTouchStart={keepTouchOnInput}
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}
type="button"
tabIndex={-1}
onMouseDown={keepTouchOnInput}
onPointerDown={keepTouchOnInput}
onTouchStart={keepTouchOnInput}
onClick={() => {
setClientFieldIndex((index) => Math.max(0, index - 1))
holdKeyboardOpen()
}}
>
<ArrowLeft className="h-6 w-6" />
Retour
</Button>
<Button
variant="outline"
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
type="button"
tabIndex={-1}
onMouseDown={keepTouchOnInput}
onPointerDown={keepTouchOnInput}
onTouchStart={keepTouchOnInput}
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">{formatCurrency(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">{formatCurrency(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>{formatCurrency(line.montantTTC)}</strong>
</div>
))}
{sale.notes && <p className="muted">{sale.notes}</p>}
<div className="row">
<span>HT</span>
<span>{formatCurrency(sale.montantHT)}</span>
</div>
<div className="row">
<span>TVA</span>
<span>{formatCurrency(sale.montantTVA)}</span>
</div>
<div className="row total">
<span>Total TTC</span>
<span>{formatCurrency(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>
)
}

View 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>
)
}

View 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
View 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
View 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,
}

27
src/lib/hermes-auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
const DEV_HERMES_KEY = 'hermes-demo-key-change-me'
export function getHermesApiKey() {
return process.env.HERMES_API_KEY || (process.env.NODE_ENV !== 'production' ? DEV_HERMES_KEY : '')
}
export function requireHermesAccess(request: NextRequest) {
const expectedKey = getHermesApiKey()
const authHeader = request.headers.get('authorization') || ''
const bearerKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
const headerKey = request.headers.get('x-hermes-key') || ''
const providedKey = bearerKey || headerKey
if (!expectedKey || providedKey !== expectedKey) {
return NextResponse.json(
{
error: 'Hermes access denied',
hint: 'Send Authorization: Bearer <HERMES_API_KEY> or x-hermes-key.',
},
{ status: 401 }
)
}
return null
}

31
src/proxy.ts Normal file
View File

@@ -0,0 +1,31 @@
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('/api/hermes') ||
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).*)'],
}