Compare commits

...

11 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
f6dc5fb07f Merge branch 'main' of http://192.168.1.160:3000/Abderrahim-Mouhcine/OpticZ 2026-05-30 14:33:58 +01:00
a8c372177f Initial commit 2026-05-30 14:33:11 +01:00
185 changed files with 41574 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
package-lock.json
yarn.lock
pnpm-lock.yaml
test
*.log
local-*

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
local-*
.claude
.z-ai-config
dev.log
test
prompt
server.log
# Skills directory
/skills/
cookies.txt
*.log

117
.zscripts/build.sh Normal file
View File

@@ -0,0 +1,117 @@
#!/bin/bash
# 将 stderr 重定向到 stdout避免 execute_command 因为 stderr 输出而报错
exec 2>&1
set -e
# 获取脚本所在目录(.zscripts 目录,即 workspace-agent/.zscripts
# 使用 $0 获取脚本路径(兼容 sh 和 bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Next.js 项目路径
NEXTJS_PROJECT_DIR="/home/z/my-project"
# 检查 Next.js 项目目录是否存在
if [ ! -d "$NEXTJS_PROJECT_DIR" ]; then
echo "❌ 错误: Next.js 项目目录不存在: $NEXTJS_PROJECT_DIR"
exit 1
fi
echo "🚀 开始构建 Next.js 应用和 mini-services..."
echo "📁 Next.js 项目路径: $NEXTJS_PROJECT_DIR"
# 切换到 Next.js 项目目录
cd "$NEXTJS_PROJECT_DIR" || exit 1
# 设置环境变量
export NEXT_TELEMETRY_DISABLED=1
BUILD_DIR="/tmp/build_fullstack_$BUILD_ID"
echo "📁 清理并创建构建目录: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# 安装依赖
echo "📦 安装依赖..."
bun install
# 构建 Next.js 应用
echo "🔨 构建 Next.js 应用..."
bun run build
# 构建 mini-services
# 检查 Next.js 项目目录下是否有 mini-services 目录
if [ -d "$NEXTJS_PROJECT_DIR/mini-services" ]; then
echo "🔨 构建 mini-services..."
# 使用 workspace-agent 目录下的 mini-services 脚本
sh "$SCRIPT_DIR/mini-services-install.sh"
sh "$SCRIPT_DIR/mini-services-build.sh"
# 复制 mini-services-start.sh 到 mini-services-dist 目录
echo " - 复制 mini-services-start.sh 到 $BUILD_DIR"
cp "$SCRIPT_DIR/mini-services-start.sh" "$BUILD_DIR/mini-services-start.sh"
chmod +x "$BUILD_DIR/mini-services-start.sh"
else
echo " mini-services 目录不存在,跳过"
fi
# 将所有构建产物复制到临时构建目录
echo "📦 收集构建产物到 $BUILD_DIR..."
# 复制 Next.js standalone 构建输出
if [ -d ".next/standalone" ]; then
echo " - 复制 .next/standalone"
cp -r .next/standalone "$BUILD_DIR/next-service-dist/"
fi
# 复制 Next.js 静态文件
if [ -d ".next/static" ]; then
echo " - 复制 .next/static"
mkdir -p "$BUILD_DIR/next-service-dist/.next"
cp -r .next/static "$BUILD_DIR/next-service-dist/.next/"
fi
# 复制 public 目录
if [ -d "public" ]; then
echo " - 复制 public"
cp -r public "$BUILD_DIR/next-service-dist/"
fi
# 最后再迁移数据库到 BUILD_DIR/db
if [ "$(ls -A ./db 2>/dev/null)" ]; then
echo "🗄️ 检测到数据库文件,运行数据库迁移..."
DATABASE_URL=file:$BUILD_DIR/db/custom.db bun run db:push
echo "✅ 数据库迁移完成"
ls -lah $BUILD_DIR/db
else
echo " db 目录为空,跳过数据库迁移"
fi
# 复制 Caddyfile如果存在
if [ -f "Caddyfile" ]; then
echo " - 复制 Caddyfile"
cp Caddyfile "$BUILD_DIR/"
else
echo " Caddyfile 不存在,跳过"
fi
# 复制 start.sh 脚本
echo " - 复制 start.sh 到 $BUILD_DIR"
cp "$SCRIPT_DIR/start.sh" "$BUILD_DIR/start.sh"
chmod +x "$BUILD_DIR/start.sh"
# 打包到 $BUILD_DIR.tar.gz
PACKAGE_FILE="${BUILD_DIR}.tar.gz"
echo ""
echo "📦 打包构建产物到 $PACKAGE_FILE..."
cd "$BUILD_DIR" || exit 1
tar -czf "$PACKAGE_FILE" .
cd - > /dev/null || exit 1
# # 清理临时目录
# rm -rf "$BUILD_DIR"
echo ""
echo "✅ 构建完成!所有产物已打包到 $PACKAGE_FILE"
echo "📊 打包文件大小:"
ls -lh "$PACKAGE_FILE"

View File

@@ -0,0 +1,78 @@
#!/bin/bash
# 配置项
ROOT_DIR="/home/z/my-project/mini-services"
DIST_DIR="/tmp/build_fullstack_$BUILD_ID/mini-services-dist"
main() {
echo "🚀 开始批量构建..."
# 检查 rootdir 是否存在
if [ ! -d "$ROOT_DIR" ]; then
echo " 目录 $ROOT_DIR 不存在,跳过构建"
return
fi
# 创建输出目录(如果不存在)
mkdir -p "$DIST_DIR"
# 统计变量
success_count=0
fail_count=0
# 遍历 mini-services 目录下的所有文件夹
for dir in "$ROOT_DIR"/*; do
# 检查是否是目录且包含 package.json
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
project_name=$(basename "$dir")
# 智能查找入口文件 (按优先级查找)
entry_path=""
for entry in "src/index.ts" "index.ts" "src/index.js" "index.js"; do
if [ -f "$dir/$entry" ]; then
entry_path="$dir/$entry"
break
fi
done
if [ -z "$entry_path" ]; then
echo "⚠️ 跳过 $project_name: 未找到入口文件 (index.ts/js)"
continue
fi
echo ""
echo "📦 正在构建: $project_name..."
# 使用 bun build CLI 构建
output_file="$DIST_DIR/mini-service-$project_name.js"
if bun build "$entry_path" \
--outfile "$output_file" \
--target bun \
--minify; then
echo "$project_name 构建成功 -> $output_file"
success_count=$((success_count + 1))
else
echo "$project_name 构建失败"
fail_count=$((fail_count + 1))
fi
fi
done
if [ -f ./.zscripts/mini-services-start.sh ]; then
cp ./.zscripts/mini-services-start.sh "$DIST_DIR/mini-services-start.sh"
chmod +x "$DIST_DIR/mini-services-start.sh"
fi
echo ""
echo "🎉 所有任务完成!"
if [ $success_count -gt 0 ] || [ $fail_count -gt 0 ]; then
echo "✅ 成功: $success_count"
if [ $fail_count -gt 0 ]; then
echo "❌ 失败: $fail_count"
fi
fi
}
main

View File

@@ -0,0 +1,65 @@
#!/bin/bash
# 配置项
ROOT_DIR="/home/z/my-project/mini-services"
main() {
echo "🚀 开始批量安装依赖..."
# 检查 rootdir 是否存在
if [ ! -d "$ROOT_DIR" ]; then
echo " 目录 $ROOT_DIR 不存在,跳过安装"
return
fi
# 统计变量
success_count=0
fail_count=0
failed_projects=""
# 遍历 mini-services 目录下的所有文件夹
for dir in "$ROOT_DIR"/*; do
# 检查是否是目录且包含 package.json
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
project_name=$(basename "$dir")
echo ""
echo "📦 正在安装依赖: $project_name..."
# 进入项目目录并执行 bun install
if (cd "$dir" && bun install); then
echo "$project_name 依赖安装成功"
success_count=$((success_count + 1))
else
echo "$project_name 依赖安装失败"
fail_count=$((fail_count + 1))
if [ -z "$failed_projects" ]; then
failed_projects="$project_name"
else
failed_projects="$failed_projects $project_name"
fi
fi
fi
done
# 汇总结果
echo ""
echo "=================================================="
if [ $success_count -gt 0 ] || [ $fail_count -gt 0 ]; then
echo "🎉 安装完成!"
echo "✅ 成功: $success_count"
if [ $fail_count -gt 0 ]; then
echo "❌ 失败: $fail_count"
echo ""
echo "失败的项目:"
for project in $failed_projects; do
echo " - $project"
done
fi
else
echo " 未找到任何包含 package.json 的项目"
fi
echo "=================================================="
}
main

View File

@@ -0,0 +1,123 @@
#!/bin/sh
# 配置项
DIST_DIR="./mini-services-dist"
# 存储所有子进程的 PID
pids=""
# 清理函数:优雅关闭所有服务
cleanup() {
echo ""
echo "🛑 正在关闭所有服务..."
# 发送 SIGTERM 信号给所有子进程
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
service_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown")
echo " 关闭进程 $pid ($service_name)..."
kill -TERM "$pid" 2>/dev/null
fi
done
# 等待所有进程退出(最多等待 5 秒)
sleep 1
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
# 如果还在运行,等待最多 4 秒
timeout=4
while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
# 如果仍然在运行,强制关闭
if kill -0 "$pid" 2>/dev/null; then
echo " 强制关闭进程 $pid..."
kill -KILL "$pid" 2>/dev/null
fi
fi
done
echo "✅ 所有服务已关闭"
}
main() {
echo "🚀 开始启动所有 mini services..."
# 检查 dist 目录是否存在
if [ ! -d "$DIST_DIR" ]; then
echo " 目录 $DIST_DIR 不存在"
return
fi
# 查找所有 mini-service-*.js 文件
service_files=""
for file in "$DIST_DIR"/mini-service-*.js; do
if [ -f "$file" ]; then
if [ -z "$service_files" ]; then
service_files="$file"
else
service_files="$service_files $file"
fi
fi
done
# 计算服务文件数量
service_count=0
for file in $service_files; do
service_count=$((service_count + 1))
done
if [ $service_count -eq 0 ]; then
echo " 未找到任何 mini service 文件"
return
fi
echo "📦 找到 $service_count 个服务,开始启动..."
echo ""
# 启动每个服务
for file in $service_files; do
service_name=$(basename "$file" .js | sed 's/mini-service-//')
echo "▶️ 启动服务: $service_name..."
# 使用 bun 运行服务(后台运行)
bun "$file" &
pid=$!
if [ -z "$pids" ]; then
pids="$pid"
else
pids="$pids $pid"
fi
# 等待一小段时间检查进程是否成功启动
sleep 0.5
if ! kill -0 "$pid" 2>/dev/null; then
echo "$service_name 启动失败"
# 从字符串中移除失败的 PID
pids=$(echo "$pids" | sed "s/\b$pid\b//" | sed 's/ */ /g' | sed 's/^ *//' | sed 's/ *$//')
else
echo "$service_name 已启动 (PID: $pid)"
fi
done
# 计算运行中的服务数量
running_count=0
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
running_count=$((running_count + 1))
fi
done
echo ""
echo "🎉 所有服务已启动!共 $running_count 个服务正在运行"
echo ""
echo "💡 按 Ctrl+C 停止所有服务"
echo ""
# 等待所有后台进程
wait
}
main

126
.zscripts/start.sh Normal file
View File

@@ -0,0 +1,126 @@
#!/bin/sh
set -e
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR"
# 存储所有子进程的 PID
pids=""
# 清理函数:优雅关闭所有服务
cleanup() {
echo ""
echo "🛑 正在关闭所有服务..."
# 发送 SIGTERM 信号给所有子进程
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
service_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown")
echo " 关闭进程 $pid ($service_name)..."
kill -TERM "$pid" 2>/dev/null
fi
done
# 等待所有进程退出(最多等待 5 秒)
sleep 1
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
# 如果还在运行,等待最多 4 秒
timeout=4
while [ $timeout -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
# 如果仍然在运行,强制关闭
if kill -0 "$pid" 2>/dev/null; then
echo " 强制关闭进程 $pid..."
kill -KILL "$pid" 2>/dev/null
fi
fi
done
echo "✅ 所有服务已关闭"
exit 0
}
echo "🚀 开始启动所有服务..."
echo ""
# 切换到构建目录
cd "$BUILD_DIR" || exit 1
ls -lah
# 初始化数据库(如果存在)
if [ -d "./next-service-dist/db" ] && [ "$(ls -A ./next-service-dist/db 2>/dev/null)" ] && [ -d "/db" ]; then
echo "🗄️ 初始化数据库从 ./next-service-dist/db 到 /db..."
cp -r ./next-service-dist/db/* /db/ 2>/dev/null || echo " ⚠️ 无法复制到 /db跳过数据库初始化"
echo "✅ 数据库初始化完成"
fi
# 启动 Next.js 服务器
if [ -f "./next-service-dist/server.js" ]; then
echo "🚀 启动 Next.js 服务器..."
cd next-service-dist/ || exit 1
# 设置环境变量
export NODE_ENV=production
export PORT=${PORT:-3000}
export HOSTNAME=${HOSTNAME:-0.0.0.0}
# 后台启动 Next.js
bun server.js &
NEXT_PID=$!
pids="$NEXT_PID"
# 等待一小段时间检查进程是否成功启动
sleep 1
if ! kill -0 "$NEXT_PID" 2>/dev/null; then
echo "❌ Next.js 服务器启动失败"
exit 1
else
echo "✅ Next.js 服务器已启动 (PID: $NEXT_PID, Port: $PORT)"
fi
cd ../
else
echo "⚠️ 未找到 Next.js 服务器文件: ./next-service-dist/server.js"
fi
# 启动 mini-services
if [ -f "./mini-services-start.sh" ]; then
echo "🚀 启动 mini-services..."
# 运行启动脚本(从根目录运行,脚本内部会处理 mini-services-dist 目录)
sh ./mini-services-start.sh &
MINI_PID=$!
pids="$pids $MINI_PID"
# 等待一小段时间检查进程是否成功启动
sleep 1
if ! kill -0 "$MINI_PID" 2>/dev/null; then
echo "⚠️ mini-services 可能启动失败,但继续运行..."
else
echo "✅ mini-services 已启动 (PID: $MINI_PID)"
fi
elif [ -d "./mini-services-dist" ]; then
echo "⚠️ 未找到 mini-services 启动脚本,但目录存在"
else
echo " mini-services 目录不存在,跳过"
fi
# 启动 Caddy如果存在 Caddyfile
echo "🚀 启动 Caddy..."
# Caddy 作为前台进程运行(主进程)
echo "✅ Caddy 已启动(前台运行)"
echo ""
echo "🎉 所有服务已启动!"
echo ""
echo "💡 按 Ctrl+C 停止所有服务"
echo ""
# Caddy 作为主进程运行
exec caddy run --config Caddyfile --adapter caddyfile

2396
CAHIER_DE_TEST.md Normal file

File diff suppressed because it is too large Load Diff

23
Caddyfile Normal file
View File

@@ -0,0 +1,23 @@
:81 {
@transform_port_query {
query XTransformPort=*
}
handle @transform_port_query {
reverse_proxy localhost:{query.XTransformPort} {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
}
handle {
reverse_proxy localhost:3000 {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
}
}

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# OptiqueStock
Application de gestion de magasin d'optique — full-stack Next.js avec gestion des clients, produits, ventes, achats, atelier et rapports.
## Modules
| 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 |
## Stack
- **Framework**: Next.js 16 (App Router), React 19, TypeScript 5
- **Base de données**: SQLite via Prisma ORM
- **UI**: Tailwind CSS 4, shadcn/ui, lucide-react
- **État & données**: Zustand, TanStack Query, TanStack Table
- **Formulaires**: react-hook-form + Zod
- **Graphiques**: Recharts
- **Autre**: Framer Motion, DND Kit, Sharp, QR Code, Sonner (toasts)
## Démarrage rapide
```bash
# Cloner et configurer
bun install
echo 'DATABASE_URL="file:./dev.db"' > .env
# Base de données
bunx prisma generate
bunx prisma db push
# Lancer le serveur de développement
bun run dev
# → http://localhost:3000
```
## Scripts
| 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 |
## Structure
```
src/
├── app/
│ ├── page.tsx # SPA — commutation de modules
│ ├── layout.tsx # Layout racine
│ ├── globals.css # Styles Tailwind + shadcn
│ └── 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)
```
## API
Toutes les routes sous `/api/` suivent le pattern REST :
- `api/clients`, `api/clients/[id]`, `api/clients/[id]/patients`
- `api/patients`, `api/patients/[id]`, `api/patients/[id]/ordonnances`
- `api/produits`, `api/produits/[id]`, `api/produits/[id]/images`
- `api/fournisseurs`, `api/fournisseurs/[id]`, `api/fournisseurs/[id]/factures`
- `api/achats/factures`, `api/achats/factures/[id]`, `api/achats/factures/[id]/valider`
- `api/pos/products`, `api/pos/clients`, `api/pos/sales`, `api/pos/sales/[id]`
- `api/atelier/orders`, `api/atelier/orders/[id]`
- `api/reports/dashboard`, `api/reports/sales`, `api/reports/inventory`, `api/reports/export/*`
- `api/fichiers/[id]`
Base de données SQLite auto-contenue — aucun serveur externe requis.

View File

@@ -0,0 +1,91 @@
---
## Task ID: 3-c - full-stack-developer
### Work Task
Create the FOURNISSEURS (Supplier Management) module with supplier list, add/edit forms, supplier detail view, and active/inactive status functionality.
### Work Summary
Successfully implemented the complete FOURNISSEURS (Supplier Management) module for the OptiqueStock application. The module includes three comprehensive frontend components, full API support for CRUD operations, and integration with the main application navigation.
**Frontend Components Created:**
1. **SupplierList.tsx** - Main supplier management interface featuring:
- Full supplier table with columns for name, contact, address, products count, status, and actions
- Advanced search filtering by name, contact, email, phone, city, and postal code
- Status filter buttons (All, Active, Inactive) with dynamic counts
- Statistics cards showing total, active, and inactive supplier counts
- Color-coded status badges (green for active, gray for inactive)
- Action buttons for view, edit, activate/deactivate, and delete operations
- Toast notifications for user feedback
- Responsive design with mobile-friendly layouts
- ScrollArea for table handling with custom scrollbar styling
2. **SupplierForm.tsx** - Form dialog for creating and editing suppliers with:
- React Hook Form integration with Zod schema validation
- Four organized sections: General Information, Contact Details, Address, and Additional Information
- Fields for company name (required), contact person, phone, email, address, city, postal code, notes, and active status
- Switch control for active/inactive status toggle with explanatory text
- Support for both create and edit modes with pre-populated data
- Comprehensive form validation with French error messages
- Loading state during form submission
- Cancel and submit buttons with proper disabled states
3. **SupplierDetail.tsx** - Detailed supplier information view with:
- Complete supplier information display with appropriate icons (Phone, Mail, MapPin, Calendar, Building2)
- Product statistics and purchase invoice history
- Associated products list showing reference, designation, category, stock, and price
- Purchase invoices list showing number, date, status (BROUILLON/VALIDE), and amount
- Action buttons to edit supplier and toggle active status
- Currency formatting using French locale (EUR)
- Date formatting in French locale
- ScrollArea components for products and invoices lists
- Status badges with appropriate color coding
- Total purchase amount calculation and display
**API Routes Created/Enhanced:**
1. **GET/POST /api/fournisseurs** (Enhanced)
- Updated GET route to include product and invoice counts via _count select
- Maintains existing POST route for supplier creation with validation
- Orders suppliers alphabetically by name
2. **GET/PUT/DELETE /api/fournisseurs/[id]** (New)
- GET: Retrieve single supplier with product and invoice counts
- PUT: Update supplier with partial field support and validation
- DELETE: Delete supplier with protection for associated products and invoices
- Proper error handling and HTTP status codes
3. **GET /api/fournisseurs/[id]/factures** (New)
- Fetches all purchase invoices for a specific supplier
- Returns invoices ordered by date (newest first)
- Validates supplier existence before fetching invoices
**Integration:**
- Integrated Supplier Management module into main page navigation in `/src/app/page.tsx`
- Added import for SupplierList component
- Added case handler to render SupplierList when FOURNISSEURS module is selected
- Module is accessible from the home page dashboard via the "Fournisseurs" card
**Technical Implementation:**
- All components follow existing code patterns from client and product modules
- Used shadcn/ui components throughout: Table, Dialog, Form, Input, Button, Badge, Card, ScrollArea, Switch, Separator
- Implemented proper TypeScript interfaces for all data types (Supplier, Product, Invoice)
- Added comprehensive error handling and user feedback with toast notifications
- All forms include validation with Zod schemas
- Followed responsive design patterns with consistent Tailwind CSS styling
- Active/Inactive status system prevents selection of inactive suppliers in other modules
- Protected deletion of suppliers with associated data (products or invoices)
**Key Features:**
- Full CRUD operations for supplier management
- Search and filter functionality with real-time updates
- Active/inactive status toggle with visual feedback
- Supplier detail view showing complete information and history
- Product and invoice association tracking
- Statistics dashboard for quick overview
- Responsive design that works on mobile, tablet, and desktop
- French language UI for better localization
- Toast notifications for all user actions
- Data validation on both client and server side

View File

@@ -0,0 +1,189 @@
# Task ID: 3-f - Full-Stack Developer
## Task: Module Rapports (Reports)
### Objective
Create a comprehensive Reports (RAPPORTS) module for the OptiqueStock optical store management system with dashboard KPIs, sales reports, inventory reports, and export functionality.
### Work Completed
#### Frontend Development
1. **Created ReportsModule.tsx** (`/src/components/reports/ReportsModule.tsx`)
- Comprehensive React component with tab-based interface
- Three main sections: Dashboard, Sales Reports, Inventory Reports
- Date range filter (today, week, month, year)
- Export to CSV functionality
- Real-time data fetching with loading states
- Error handling with toast notifications
- Responsive design using Tailwind CSS
2. **Dashboard Features**
- 8 KPI cards displaying:
- Daily sales count
- Daily revenue (TTC)
- Total clients
- Pending workshop orders with alert
- Weekly sales count
- Weekly revenue
- Monthly sales count
- Monthly revenue
- Top 5 selling products with quantity and revenue
- Low stock alerts with visual indicators (red badges, triangle icons)
- Scroll areas for long lists
3. **Sales Reports Features**
- Bar chart showing sales evolution and revenue over time
- Pie chart for sales distribution by product category
- Horizontal bar chart for payment method distribution
- Table showing sales by employee with performance metrics
- Date range filtering affecting all data
4. **Inventory Reports Features**
- Total stock valuation display
- Bar chart showing stock value by category
- Detailed category breakdown table with:
- Total products count
- Active products count
- Total stock quantity
- Stock value
- Low stock items list with detailed information
5. **Visual Components**
- Used shadcn/ui components: Card, Button, Tabs, Badge, ScrollArea, Select, Table
- Integrated Recharts for data visualization:
- BarChart for sales evolution and stock valuation
- PieChart for category distribution
- Responsive charts with tooltips and legends
- Custom color scheme for different categories
- Icons from Lucide React for visual enhancement
#### Backend API Development
6. **Created Dashboard API** (`/src/app/api/reports/dashboard/route.ts`)
- Endpoint: `GET /api/reports/dashboard`
- Calculates sales counts for today, week, month, year
- Computes revenue (HT and TTC) for today and month
- Retrieves total client count
- Identifies top 5 selling products by quantity
- Lists products below minimum stock level
- Counts pending workshop orders (EN_ATTENTE status)
- Returns aggregated KPI data
7. **Created Sales Reports API** (`/src/app/api/reports/sales/route.ts`)
- Endpoint: `GET /api/reports/sales?range={today|week|month|year}`
- Sales by date with grouping and formatting
- Sales by product category with count and revenue
- Sales by employee with performance metrics
- Sales by payment method with amount distribution
- Date range filtering using date-fns
- Proper French labels for payment methods
8. **Created Inventory Reports API** (`/src/app/api/reports/inventory/route.ts`)
- Endpoint: `GET /api/reports/inventory`
- Stock valuation by category
- Total stock value calculation
- Low stock items with value calculation
- Category breakdown with comprehensive statistics
- Active/inactive product filtering
9. **Created Export APIs**
- **Sales Export** (`/src/app/api/reports/export/sales/route.ts`)
- Endpoint: `GET /api/reports/export/sales?range={today|week|month|year}`
- Exports all sales with details (client, employee, products, payments)
- CSV format with proper escaping
- Includes: sale number, date, client, employee, products, quantities, amounts, discount, payment methods, workshop status
- French date formatting
- **Inventory Export** (`/src/app/api/reports/export/inventory/route.ts`)
- Endpoint: `GET /api/reports/export/inventory`
- Exports all active products with stock information
- CSV format with comprehensive product data
- Includes: reference, designation, category, brand, supplier, stock levels, prices, TVA, stock value, location, barcode, stock status
- **Low Stock Export** (`/src/app/api/reports/export/lowStock/route.ts`)
- Endpoint: `GET /api/reports/export/lowStock`
- Exports products below minimum stock level
- CSV format with reordering information
- Includes: reference, designation, category, brand, supplier, current stock, minimum stock, deficit, purchase price, order value, location, barcode
#### Integration
10. **Integrated Reports Module into Main Navigation**
- Added ReportsModule import to `/src/app/page.tsx`
- Created render condition for RAPPORTS module
- Consistent header styling with other modules (cyan color scheme)
- Back to home button navigation
- Module accessible from home page navigation grid
### Technical Implementation Details
- **State Management**: React useState for managing tabs, date range, loading states, and data
- **Data Fetching**: useEffect hooks with dependency arrays for reactive data loading
- **Error Handling**: Try-catch blocks with toast notifications for user feedback
- **Type Safety**: TypeScript interfaces for all data structures
- **Date Handling**: date-fns library for date manipulation and formatting
- **Chart Configuration**: Custom chart config object with category colors
- **CSV Generation**: Client-side download using Blob and URL.createObjectURL
- **API Design**: RESTful endpoints with proper error handling and JSON responses
- **Database Queries**: Prisma aggregations and groupBy for efficient data summarization
- **Performance**: Parallel database queries using Promise.all where applicable
### Files Created
1. `/src/components/reports/ReportsModule.tsx` - Main reports frontend component
2. `/src/app/api/reports/dashboard/route.ts` - Dashboard data API
3. `/src/app/api/reports/sales/route.ts` - Sales reports API
4. `/src/app/api/reports/inventory/route.ts` - Inventory reports API
5. `/src/app/api/reports/export/sales/route.ts` - Sales CSV export API
6. `/src/app/api/reports/export/inventory/route.ts` - Inventory CSV export API
7. `/src/app/api/reports/export/lowStock/route.ts` - Low stock CSV export API
### Files Modified
1. `/src/app/page.tsx` - Added ReportsModule import and render condition
### Key Features Delivered
✅ Dashboard with 8 KPIs (sales, revenue, clients, workshop orders)
✅ Top 5 selling products display
✅ Low stock alerts with visual indicators
✅ Sales reports by date, category, employee, and payment method
✅ Inventory reports with stock valuation and category breakdown
✅ Interactive charts using Recharts (bar charts, pie charts)
✅ Date range filtering for reports
✅ CSV export functionality for sales, inventory, and low stock
✅ Responsive design for mobile and desktop
✅ Consistent styling with shadcn/ui components
✅ French language interface
✅ Error handling and user feedback
✅ Integration with main application navigation
### Testing Notes
- Module is accessible from the home page under "Rapports"
- All API endpoints follow RESTful conventions
- CSV exports work with proper encoding and formatting
- Charts are responsive and include tooltips
- Date range filtering updates data dynamically
- Loading states provide good user feedback
- Error messages are displayed via toast notifications
### Limitations
- CSV export only (Excel and PDF not implemented due to library constraints)
- Charts use English labels internally but data is French
- No real-time updates (requires manual refresh or tab switch)
- Employee data may show "Inconnu" if employeId is null
- Some KPIs may show 0 if no data exists in the database
### Next Steps (Optional Enhancements)
- Add Excel export using xlsx library
- Add PDF export using jsPDF or similar
- Implement real-time updates with polling or WebSocket
- Add more advanced date range picker (custom date ranges)
- Add drill-down capabilities for chart items
- Add comparison views (e.g., compare with previous period)
- Add more report types (employee performance, product trends, etc.)
- Implement report scheduling and email delivery
- Add print-friendly layouts for reports

2025
bun.lock Normal file

File diff suppressed because it is too large Load Diff

43
check-patients.js Normal file
View File

@@ -0,0 +1,43 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkPatients() {
console.log('\n=== TOUTES LES FICHES VISION ===');
const allPatients = await prisma.patient.findMany({
include: { client: true },
orderBy: { dateCreation: 'desc' }
});
console.log('Nombre total de patients:', allPatients.length);
allPatients.forEach((p, index) => {
console.log(`\n${index + 1}. Patient ID: ${p.id}`);
console.log(` Client ID: ${p.clientId}`);
console.log(` Client associé: ${p.client?.nom} ${p.client?.prenom}`);
console.log(` Date création: ${p.dateCreation}`);
console.log(` OD Sphere: ${p.odSphere}, OD Cylindre: ${p.odCylindre}`);
});
console.log('\n=== PAR CLIENT ===');
const allClients = await prisma.client.findMany({
include: {
patients: true
},
orderBy: { createdAt: 'desc' }
});
allClients.forEach((client) => {
console.log(`\n--- ${client.nom} ${client.prenom} (ID: ${client.id}) ---`);
console.log(` Nombre de fiches vision: ${client.patients.length}`);
client.patients.forEach((p, idx) => {
console.log(` ${idx + 1}. Patient ID: ${p.id} (clientId: ${p.clientId}) - OD: ${p.odSphere}/${p.odCylindre}`);
if (p.clientId !== client.id) {
console.log(` ⚠️ ERREUR: clientId du patient (${p.clientId}) ≠ ID du client (${client.id})`);
}
});
});
await prisma.$disconnect();
}
checkPatients().catch(console.error);

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

BIN
db/custom.db Normal file

Binary file not shown.

1
download/README.md Normal file
View File

@@ -0,0 +1 @@
Here are all the generated files.

50
eslint.config.mjs Normal file
View File

@@ -0,0 +1,50 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
rules: {
// TypeScript rules
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/prefer-as-const": "off",
"@typescript-eslint/no-unused-disable-directive": "off",
// React rules
"react-hooks/exhaustive-deps": "off",
"react-hooks/purity": "off",
"react/no-unescaped-entities": "off",
"react/display-name": "off",
"react/prop-types": "off",
"react-compiler/react-compiler": "off",
// Next.js rules
"@next/next/no-img-element": "off",
"@next/next/no-html-link-for-pages": "off",
// General JavaScript rules
"prefer-const": "off",
"no-unused-vars": "off",
"no-console": "off",
"no-debugger": "off",
"no-empty": "off",
"no-irregular-whitespace": "off",
"no-case-declarations": "off",
"no-fallthrough": "off",
"no-mixed-spaces-and-tabs": "off",
"no-redeclare": "off",
"no-undef": "off",
"no-unreachable": "off",
"no-useless-escape": "off",
},
}, {
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
}];
export default eslintConfig;

View File

@@ -0,0 +1,196 @@
'use client';
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
type User = {
id: string;
username: string;
}
type Message = {
id: string;
username: string;
content: string;
timestamp: Date | string;
type: 'user' | 'system';
}
export default function SocketDemo() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [username, setUsername] = useState('');
const [isUsernameSet, setIsUsernameSet] = useState(false);
const [socket, setSocket] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false);
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
// Connect to websocket server
// Never use PORT in the URL, alyways use XTransformPort
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
const socketInstance = io('/?XTransformPort=3003', {
transports: ['websocket', 'polling'],
forceNew: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 10000
})
setSocket(socketInstance);
socketInstance.on('connect', () => {
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
setIsConnected(false);
});
socketInstance.on('message', (msg: Message) => {
setMessages(prev => [...prev, msg]);
});
socketInstance.on('user-joined', (data: { user: User; message: Message }) => {
setMessages(prev => [...prev, data.message]);
setUsers(prev => {
if (!prev.find(u => u.id === data.user.id)) {
return [...prev, data.user];
}
return prev;
});
});
socketInstance.on('user-left', (data: { user: User; message: Message }) => {
setMessages(prev => [...prev, data.message]);
setUsers(prev => prev.filter(u => u.id !== data.user.id));
});
socketInstance.on('users-list', (data: { users: User[] }) => {
setUsers(data.users);
});
return () => {
socketInstance.disconnect();
};
}, []);
const handleJoin = () => {
if (socket && username.trim() && isConnected) {
socket.emit('join', { username: username.trim() });
setIsUsernameSet(true);
}
};
const sendMessage = () => {
if (socket && inputMessage.trim() && username.trim()) {
socket.emit('message', {
content: inputMessage.trim(),
username: username.trim()
});
setInputMessage('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
sendMessage();
}
};
return (
<div className="container mx-auto p-4 max-w-2xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
WebSocket Demo
<span className={`text-sm px-2 py-1 rounded ${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!isUsernameSet ? (
<div className="space-y-2">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleJoin();
}
}}
placeholder="Enter your username..."
disabled={!isConnected}
className="flex-1"
/>
<Button
onClick={handleJoin}
disabled={!isConnected || !username.trim()}
className="w-full"
>
Join Chat
</Button>
</div>
) : (
<>
<ScrollArea className="h-80 w-full border rounded-md p-4">
<div className="space-y-2">
{messages.length === 0 ? (
<p className="text-gray-500 text-center">No messages yet</p>
) : (
messages.map((msg) => (
<div key={msg.id} className="border-b pb-2 last:border-b-0">
<div className="flex justify-between items-start">
<div className="flex-1">
<p className={`text-sm font-medium ${msg.type === 'system'
? 'text-blue-600 italic'
: 'text-gray-700'
}`}>
{msg.username}
</p>
<p className={`${msg.type === 'system'
? 'text-blue-500 italic'
: 'text-gray-900'
}`}>
{msg.content}
</p>
</div>
<span className="text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
))
)}
</div>
</ScrollArea>
<div className="flex space-x-2">
<Input
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
disabled={!isConnected}
className="flex-1"
/>
<Button
onClick={sendMessage}
disabled={!isConnected || !inputMessage.trim()}
>
Send
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { createServer } from 'http'
import { Server } from 'socket.io'
const httpServer = createServer()
const io = new Server(httpServer, {
// DO NOT change the path, it is used by Caddy to forward the request to the correct port
path: '/',
cors: {
origin: "*",
methods: ["GET", "POST"]
},
pingTimeout: 60000,
pingInterval: 25000,
})
interface User {
id: string
username: string
}
interface Message {
id: string
username: string
content: string
timestamp: Date
type: 'user' | 'system'
}
const users = new Map<string, User>()
const generateMessageId = () => Math.random().toString(36).substr(2, 9)
const createSystemMessage = (content: string): Message => ({
id: generateMessageId(),
username: 'System',
content,
timestamp: new Date(),
type: 'system'
})
const createUserMessage = (username: string, content: string): Message => ({
id: generateMessageId(),
username,
content,
timestamp: new Date(),
type: 'user'
})
io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`)
// Add test event handler
socket.on('test', (data) => {
console.log('Received test message:', data)
socket.emit('test-response', {
message: 'Server received test message',
data: data,
timestamp: new Date().toISOString()
})
})
socket.on('join', (data: { username: string }) => {
const { username } = data
// Create user object
const user: User = {
id: socket.id,
username
}
// Add to user list
users.set(socket.id, user)
// Send join message to all users
const joinMessage = createSystemMessage(`${username} joined the chat room`)
io.emit('user-joined', { user, message: joinMessage })
// Send current user list to new user
const usersList = Array.from(users.values())
socket.emit('users-list', { users: usersList })
console.log(`${username} joined the chat room, current online users: ${users.size}`)
})
socket.on('message', (data: { content: string; username: string }) => {
const { content, username } = data
const user = users.get(socket.id)
if (user && user.username === username) {
const message = createUserMessage(username, content)
io.emit('message', message)
console.log(`${username}: ${content}`)
}
})
socket.on('disconnect', () => {
const user = users.get(socket.id)
if (user) {
// Remove from user list
users.delete(socket.id)
// Send leave message to all users
const leaveMessage = createSystemMessage(`${user.username} left the chat room`)
io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage })
console.log(`${user.username} left the chat room, current online users: ${users.size}`)
} else {
console.log(`User disconnected: ${socket.id}`)
}
})
socket.on('error', (error) => {
console.error(`Socket error (${socket.id}):`, error)
})
})
const PORT = 3003
httpServer.listen(PORT, () => {
console.log(`WebSocket server running on port ${PORT}`)
})
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Received SIGTERM signal, shutting down server...')
httpServer.close(() => {
console.log('WebSocket server closed')
process.exit(0)
})
})
process.on('SIGINT', () => {
console.log('Received SIGINT signal, shutting down server...')
httpServer.close(() => {
console.log('WebSocket server closed')
process.exit(0)
})
})

0
mini-services/.gitkeep Normal file
View File

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',
},
})
}
}
})

13
next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.30:3000"],
/* config options here */
typescript: {
ignoreBuildErrors: true,
},
reactStrictMode: false,
};
export default nextConfig;

12480
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

99
package.json Normal file
View File

@@ -0,0 +1,99 @@
{
"name": "nextjs_tailwind_shadcn_ts",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000 2>&1 | tee dev.log",
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
"lint": "eslint .",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset",
"hermes:mcp": "node mini-services/hermes-mcp/server.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@mdxeditor/editor": "^3.39.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@reactuses/core": "^6.0.5",
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6",
"prisma": "^6.11.1",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.3",
"react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"z-ai-web-dev-sdk": "^0.0.16",
"zod": "^4.0.2",
"zustand": "^5.0.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "^1.3.4",
"eslint": "^9",
"eslint-config-next": "^16.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

BIN
prisma/dev.db Normal file

Binary file not shown.

348
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,348 @@
// Schéma de base de données pour la Gestion de Magasin d'Optique
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ============================================
// ÉNUMÉRATIONS
// ============================================
enum StatutVente {
INITIEE
PAYEE
ANNULEE
REMBOURSEE
}
enum StatutAchat {
BROUILLON
VALIDE
}
enum ModePaiement {
ESPECES
CARTE
CHEQUE
VIREMENT
BON_CAISSE
}
enum TypeFichier {
ORDONNANCE
FACTURE_ACHAT
FACTURE_VENTE
IMAGE_PRODUIT
}
enum RoleEmploye {
VENDEUR
RESPONSABLE
ADMIN
}
enum TypeMonture {
COMPLET
NATUREL
CERCLAGE
}
enum TypeVerre {
SIMPLE
BIFOCAL
PROGRESSIF
}
enum StatutAtelier {
EN_ATTENTE
EN_COURS
TERMINE
PRET
RETIRE
}
// ============================================
// MODÈLES PRINCIPAUX
// ============================================
// Employé du magasin
model Employe {
id String @id @default(cuid())
email String @unique
nom String
prenom String
role RoleEmploye @default(VENDEUR)
actif Boolean @default(true)
motDePasse String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
ventes Vente[]
facturesAchat FactureAchat[]
retours Retour[]
paiements Paiement[]
patients Patient[]
}
// Client / Patient
model Client {
id String @id @default(cuid())
nom String
prenom String
email String?
telephone String @unique
adresse String?
ville String?
codePostal String?
dateNaissance DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
patients Patient[]
ventes Vente[]
}
// Patient avec données de vision
model Patient {
id String @id @default(cuid())
clientId String
dateCreation DateTime @default(now())
odSphere Float? // Œil droit - Sphère
odCylindre Float? // Œil droit - Cylindre
odAxe Int? // Œil droit - Axe
ogSphere Float? // Œil gauche - Sphère
ogCylindre Float? // Œil gauche - Cylindre
ogAxe Int? // Œil gauche - Axe
addition Float? // Addition
pd Float? // Distance pupillaire
hauteur Float? // Hauteur
notes String?
employeId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
employe Employe? @relation(fields: [employeId], references: [id])
ordonnances Ordonnance[]
}
// Ordonnance (prescription médicale)
model Ordonnance {
id String @id @default(cuid())
patientId String
numero String @unique
dateEmission DateTime
medecin String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
fichiers Fichier[]
}
// Fournisseur
model Fournisseur {
id String @id @default(cuid())
nom String
contact String?
email String?
telephone String?
adresse String?
ville String?
codePostal String?
notes String?
actif Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
facturesAchat FactureAchat[]
produits Produit[]
}
// Produit (lunettes, lentilles, accessoires, verres)
model Produit {
id String @id @default(cuid())
reference String @unique
designation String
categorie String // MONTURE, VERRE, LENTILLE, ACCESSOIRE
fournisseurId String?
prixAchatHT Float
prixVenteTTC Float
tva Float @default(20.0)
stock Int @default(0)
stockMin Int @default(5)
emplacement String? // Rayon, étagère
marque String?
typeMonture TypeMonture?
typeVerre TypeVerre?
indice Float? // Indice de réfraction (1.5, 1.6, etc.)
materiau String? // Acétate, métal, titane, etc.
couleur String?
dimensions String? // Largeur, pont, branches (ex: 54-18-140)
description String?
codeBarre String? @unique
actif Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
fournisseur Fournisseur? @relation(fields: [fournisseurId], references: [id])
ligneVente LigneVente[]
ligneFacture LigneFacture[]
fichiers Fichier[]
}
// Vente
model Vente {
id String @id @default(cuid())
clientId String?
numero String @unique
date DateTime @default(now())
statut StatutVente @default(INITIEE)
statutAtelier StatutAtelier @default(EN_ATTENTE)
montantHT Float
montantTVA Float
montantTTC Float
remise Float @default(0)
notes String?
employeId String?
dateAtelier DateTime?
dateRetrait DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
client Client? @relation(fields: [clientId], references: [id])
employe Employe? @relation(fields: [employeId], references: [id])
lignes LigneVente[]
paiements Paiement[]
retours Retour[]
fichiers Fichier[]
}
// Ligne de vente
model LigneVente {
id String @id @default(cuid())
venteId String
produitId String
quantite Int @default(1)
prixUnitaireHT Float
prixUnitaireTTC Float
remise Float @default(0)
montantHT Float
montantTTC Float
createdAt DateTime @default(now())
// Relations
vente Vente @relation(fields: [venteId], references: [id], onDelete: Cascade)
produit Produit @relation(fields: [produitId], references: [id])
}
// Paiement
model Paiement {
id String @id @default(cuid())
venteId String
mode ModePaiement
montant Float
date DateTime @default(now())
reference String?
notes String?
employeId String?
createdAt DateTime @default(now())
// Relations
vente Vente @relation(fields: [venteId], references: [id], onDelete: Cascade)
employe Employe? @relation(fields: [employeId], references: [id])
}
// Facture d'achat
model FactureAchat {
id String @id @default(cuid())
fournisseurId String
numero String @unique
date DateTime @default(now())
dateReception DateTime?
statut StatutAchat @default(BROUILLON)
montantHT Float
montantTVA Float
montantTTC Float
notes String?
employeId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
fournisseur Fournisseur @relation(fields: [fournisseurId], references: [id])
employe Employe? @relation(fields: [employeId], references: [id])
lignes LigneFacture[]
fichiers Fichier[]
}
// Ligne de facture d'achat
model LigneFacture {
id String @id @default(cuid())
factureId String
produitId String
quantite Int @default(1)
prixUnitaireHT Float
tauxTVA Float @default(20.0)
montantHT Float
montantTVA Float
montantTTC Float
createdAt DateTime @default(now())
// Relations
facture FactureAchat @relation(fields: [factureId], references: [id], onDelete: Cascade)
produit Produit @relation(fields: [produitId], references: [id])
}
// Retour / SAV
model Retour {
id String @id @default(cuid())
venteId String?
numero String @unique
date DateTime @default(now())
motif String
montant Float
notes String?
employeId String?
createdAt DateTime @default(now())
// Relations
vente Vente? @relation(fields: [venteId], references: [id])
employe Employe? @relation(fields: [employeId], references: [id])
}
// Fichier / Document
model Fichier {
id String @id @default(cuid())
nom String
type TypeFichier
url String
taille Int
mimeType String
createdAt DateTime @default(now())
// Relations - Optionnelles selon le type
ordonnanceId String?
venteId String?
factureAchatId String?
produitId String?
ordonnance Ordonnance? @relation(fields: [ordonnanceId], references: [id])
vente Vente? @relation(fields: [venteId], references: [id])
factureAchat FactureAchat? @relation(fields: [factureAchatId], references: [id])
produit Produit? @relation(fields: [produitId], references: [id])
}

29
public/logo.svg Normal file
View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<defs>
<style type="text/css">
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
.st23{fill:#FFFFFF;}
.z-breathe {
animation: breathe 2.5s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
</style>
</defs>
<g>
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
<g class="z-breathe">
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1"/>
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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"
}
]
}

14
public/robots.txt Normal file
View File

@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET single purchase invoice
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const invoice = await db.factureAchat.findUnique({
where: { id: id },
include: {
fournisseur: true,
lignes: {
include: {
produit: true
}
},
fichiers: true
}
})
if (!invoice) {
return NextResponse.json(
{ error: 'Facture non trouvée' },
{ status: 404 }
)
}
return NextResponse.json(invoice)
} catch (error) {
console.error('Error fetching purchase invoice:', error)
return NextResponse.json(
{ error: 'Impossible de récupérer la facture' },
{ status: 500 }
)
}
}
// DELETE purchase invoice (only if BROUILLON)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Check if invoice exists and is in BROUILLON status
const invoice = await db.factureAchat.findUnique({
where: { id: id }
})
if (!invoice) {
return NextResponse.json(
{ error: 'Facture non trouvée' },
{ status: 404 }
)
}
if (invoice.statut !== 'BROUILLON') {
return NextResponse.json(
{ error: 'Seules les factures en brouillon peuvent être supprimées' },
{ status: 400 }
)
}
// Delete invoice (lines will be cascade deleted)
await db.factureAchat.delete({
where: { id: id }
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting purchase invoice:', error)
return NextResponse.json(
{ error: 'Impossible de supprimer la facture' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// POST validate invoice and update stock
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get invoice with lines
const invoice = await db.factureAchat.findUnique({
where: { id: id },
include: {
lignes: true
}
})
if (!invoice) {
return NextResponse.json(
{ error: 'Facture non trouvée' },
{ status: 404 }
)
}
if (invoice.statut !== 'BROUILLON') {
return NextResponse.json(
{ error: 'Cette facture a déjà été validée' },
{ status: 400 }
)
}
// Update stock for each line
for (const ligne of invoice.lignes) {
await db.produit.update({
where: { id: ligne.produitId },
data: {
stock: {
increment: ligne.quantite
}
}
})
}
// Update invoice status and set reception date
const updatedInvoice = await db.factureAchat.update({
where: { id: id },
data: {
statut: 'VALIDE',
dateReception: new Date()
},
include: {
fournisseur: true,
lignes: {
include: {
produit: true
}
}
}
})
return NextResponse.json(updatedInvoice)
} catch (error) {
console.error('Error validating purchase invoice:', error)
return NextResponse.json(
{ error: 'Impossible de valider la facture' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET all purchase invoices
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const statut = searchParams.get('statut')
const invoices = await db.factureAchat.findMany({
where: statut ? { statut: statut as any } : undefined,
include: {
fournisseur: true,
lignes: {
include: {
produit: true
}
},
fichiers: true
},
orderBy: {
date: 'desc'
}
})
return NextResponse.json(invoices)
} catch (error) {
console.error('Error fetching purchase invoices:', error)
return NextResponse.json(
{ error: 'Impossible de récupérer les factures d\'achat' },
{ status: 500 }
)
}
}
// POST create new purchase invoice
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
fournisseurId,
date,
lignes,
montantHT,
montantTVA,
montantTTC,
notes
} = body
// Validation
if (!fournisseurId) {
return NextResponse.json(
{ error: 'Le fournisseur est requis' },
{ status: 400 }
)
}
if (!lignes || lignes.length === 0) {
return NextResponse.json(
{ error: 'Au moins une ligne est requise' },
{ status: 400 }
)
}
// Generate invoice number
const now = new Date(date)
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
// Count invoices for this month
const monthInvoices = await db.factureAchat.count({
where: {
date: {
gte: new Date(year, now.getMonth(), 1),
lt: new Date(year, now.getMonth() + 1, 1)
}
}
})
const numero = `A${year}${month}${String(monthInvoices + 1).padStart(4, '0')}`
// Create invoice with lines in a transaction
const invoice = await db.factureAchat.create({
data: {
fournisseurId,
numero,
date: new Date(date),
statut: 'BROUILLON',
montantHT: Number(montantHT),
montantTVA: Number(montantTVA),
montantTTC: Number(montantTTC),
notes: notes || null,
lignes: {
create: lignes.map((ligne: any) => ({
produitId: ligne.produitId,
quantite: ligne.quantite,
prixUnitaireHT: ligne.prixUnitaireHT,
tauxTVA: ligne.tauxTVA,
montantHT: ligne.montantHT,
montantTVA: ligne.montantTVA,
montantTTC: ligne.montantTTC
}))
}
},
include: {
fournisseur: true,
lignes: {
include: {
produit: true
}
}
}
})
return NextResponse.json(invoice, { status: 201 })
} catch (error) {
console.error('Error creating purchase invoice:', error)
return NextResponse.json(
{ error: 'Impossible de créer la facture d\'achat' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import { db } from '@/lib/db'
import { existsSync } from 'fs'
import path from 'path'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
const factureAchatId = formData.get('factureAchatId') as string
const type = formData.get('type') as string
const nom = formData.get('nom') as string
if (!file) {
return NextResponse.json(
{ error: 'Aucun fichier fourni' },
{ status: 400 }
)
}
if (!factureAchatId) {
return NextResponse.json(
{ error: 'ID de facture requis' },
{ status: 400 }
)
}
// Validate file type (PDF only)
if (file.type !== 'application/pdf') {
return NextResponse.json(
{ error: 'Seuls les fichiers PDF sont acceptés' },
{ status: 400 }
)
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'La taille du fichier ne peut pas dépasser 10MB' },
{ status: 400 }
)
}
// Create upload directory if it doesn't exist
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'purchases')
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })
}
// Generate unique filename
const timestamp = Date.now()
const filename = `${factureAchatId}_${timestamp}_${file.name}`
const filepath = path.join(uploadDir, filename)
// Write file
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(filepath, buffer)
// Create file record in database
const fichier = await db.fichier.create({
data: {
nom: nom || file.name,
type: type as any,
url: `/uploads/purchases/${filename}`,
taille: file.size,
mimeType: file.type,
factureAchatId: factureAchatId
}
})
return NextResponse.json(fichier, { status: 201 })
} catch (error) {
console.error('Error uploading file:', error)
return NextResponse.json(
{ error: 'Impossible de télécharger le fichier' },
{ status: 500 }
)
}
}

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,54 @@
import { NextRequest, NextResponse } from 'next/server';
import ZAI from 'z-ai-web-dev-sdk';
import fs from 'fs';
export async function POST(req: NextRequest) {
try {
const { imagePath } = await req.json();
if (!imagePath) {
return NextResponse.json({ error: 'imagePath is required' }, { status: 400 });
}
const imageBuffer = fs.readFileSync(imagePath);
const base64Image = imageBuffer.toString('base64');
const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
const zai = await ZAI.create();
const response = await zai.chat.completions.createVision({
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Analyze this image and describe what it shows in detail. If it contains text, extract all the text. If it\'s a UI design or application screenshot, describe all the features, components, and functionality shown.'
},
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${base64Image}`
}
}
]
}
],
thinking: { type: 'disabled' }
});
const content = response.choices[0]?.message?.content;
return NextResponse.json({
success: true,
analysis: content
});
} catch (error) {
console.error('Image analysis error:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}

View File

@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutAtelier } from '@prisma/client'
// POST /api/atelier/orders/[id] - Update workshop status
// Note: Using POST instead of PATCH because the gateway blocks PATCH requests
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const { statutAtelier } = body
console.log('API: POST /api/atelier/orders/[id]')
console.log('API: orderId =', id)
console.log('API: statutAtelier =', statutAtelier)
console.log('API: Valid StatutAtelier values:', Object.values(StatutAtelier))
// Validate status
if (!statutAtelier || !Object.values(StatutAtelier).includes(statutAtelier)) {
console.log('API: Invalid status - returning 400')
return NextResponse.json(
{ error: 'Invalid workshop status' },
{ status: 400 }
)
}
// Check if order exists
const existingOrder = await db.vente.findUnique({
where: { id: id }
})
if (!existingOrder) {
return NextResponse.json(
{ error: 'Order not found' },
{ status: 404 }
)
}
// Update order status
const updateData: any = {
statutAtelier: statutAtelier as StatutAtelier,
dateAtelier: new Date()
}
// Add dateRetrait if status is RETIRE
if (statutAtelier === 'RETIRE') {
updateData.dateRetrait = new Date()
}
const updatedOrder = await db.vente.update({
where: { id: id },
data: updateData,
include: {
client: true,
lignes: {
include: {
produit: true
},
where: {
produit: {
categorie: {
in: ['MONTURE', 'VERRE']
}
}
}
},
paiements: true
}
})
console.log('API: Order updated successfully, new status:', updatedOrder.statutAtelier)
// Fetch patients for the client
let patients = []
if (updatedOrder.clientId) {
patients = await db.patient.findMany({
where: {
clientId: updatedOrder.clientId
},
orderBy: {
dateCreation: 'desc'
},
take: 2
})
}
return NextResponse.json({
...updatedOrder,
patients
})
} catch (error) {
console.error('Error updating work order:', error)
return NextResponse.json(
{ error: 'Failed to update work order' },
{ status: 500 }
)
}
}
// GET /api/atelier/orders/[id] - Get specific work order details
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const order = await db.vente.findUnique({
where: { id: id },
include: {
client: true,
lignes: {
include: {
produit: true
}
},
paiements: true
}
})
if (!order) {
return NextResponse.json(
{ error: 'Order not found' },
{ status: 404 }
)
}
// Fetch patients for the client
let patients = []
if (order.clientId) {
patients = await db.patient.findMany({
where: {
clientId: order.clientId
},
orderBy: {
dateCreation: 'desc'
},
take: 2
})
}
return NextResponse.json({
...order,
patients
})
} catch (error) {
console.error('Error fetching work order:', error)
return NextResponse.json(
{ error: 'Failed to fetch work order' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutAtelier } from '@prisma/client'
// GET /api/atelier/orders - Get all work orders (sales that need mounting)
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const status = searchParams.get('status') as StatutAtelier | null
// Build where clause
const where: any = {
statut: 'PAYEE', // Only paid sales go to workshop
lignes: {
some: {
produit: {
categorie: {
in: ['MONTURE', 'VERRE'] // Only sales with frames or lenses
}
}
}
}
}
// Filter by status if provided
if (status && status !== 'ALL') {
where.statutAtelier = status
}
const workOrders = await db.vente.findMany({
where,
include: {
client: true,
lignes: {
include: {
produit: true
},
where: {
produit: {
categorie: {
in: ['MONTURE', 'VERRE']
}
}
}
},
paiements: true
},
orderBy: [
{ statutAtelier: 'asc' }, // EN_ATTENTE first, then EN_COURS, etc.
{ date: 'desc' }
]
})
// Fetch patients for each client to get vision measurements
const workOrdersWithPatients = await Promise.all(
workOrders.map(async (order) => {
if (!order.clientId) {
return { ...order, patients: [] }
}
const patients = await db.patient.findMany({
where: {
clientId: order.clientId
},
orderBy: {
dateCreation: 'desc'
},
take: 2 // Get the 2 most recent vision measurements
})
return {
...order,
patients
}
})
)
return NextResponse.json(workOrdersWithPatients)
} catch (error) {
console.error('Error fetching work orders:', error)
return NextResponse.json(
{ error: 'Failed to fetch work orders' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,172 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutVente, StatutAtelier } from '@prisma/client'
// POST /api/atelier/seed - Seed sample work order data for testing
export async function POST(request: NextRequest) {
try {
// Check if we already have test data
const existingOrders = await db.vente.count({
where: {
statut: StatutVente.PAYEE
}
})
if (existingOrders > 0) {
return NextResponse.json(
{
message: 'Test data already exists',
count: existingOrders
},
{ status: 200 }
)
}
// Get or create sample products
const frames = await db.produit.findMany({
where: { categorie: 'MONTURE' },
take: 3
})
const lenses = await db.produit.findMany({
where: { categorie: 'VERRE' },
take: 3
})
// Get or create a sample client
let client = await db.client.findFirst()
if (!client) {
client = await db.client.create({
data: {
nom: 'Dupont',
prenom: 'Jean',
email: 'jean.dupont@email.com',
telephone: '0612345678',
adresse: '123 Rue de la République',
ville: 'Paris',
codePostal: '75001'
}
})
}
// Create vision measurements for the client
let patient = await db.patient.findFirst({
where: { clientId: client.id }
})
if (!patient) {
patient = await db.patient.create({
data: {
clientId: client.id,
odSphere: -2.00,
odCylindre: -0.50,
odAxe: 180,
ogSphere: -1.75,
ogCylindre: -0.75,
ogAxe: 175,
addition: 1.50,
pd: 63,
hauteur: 22
}
})
}
// Create sample sales with different workshop statuses
const workOrderData = [
{
statutAtelier: StatutAtelier.EN_ATTENTE,
products: [
frames[0],
lenses[0]
]
},
{
statutAtelier: StatutAtelier.EN_COURS,
products: [
frames[1] || frames[0],
lenses[1] || lenses[0]
]
},
{
statutAtelier: StatutAtelier.TERMINE,
products: [
frames[2] || frames[0],
lenses[2] || lenses[0]
]
},
{
statutAtelier: StatutAtelier.PRET,
products: [
frames[0],
lenses[1] || lenses[0]
]
}
]
const createdOrders = []
for (const orderData of workOrderData) {
const totalHT = orderData.products.reduce((sum, p) => sum + p.prixVenteTTC / 1.2, 0)
const totalTVA = orderData.products.reduce((sum, p) => sum + p.prixVenteTTC - p.prixVenteTTC / 1.2, 0)
const totalTTC = orderData.products.reduce((sum, p) => sum + p.prixVenteTTC, 0)
const order = await db.vente.create({
data: {
clientId: client.id,
numero: `V${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}${String(createdOrders.length + 1).padStart(4, '0')}`,
statut: StatutVente.PAYEE,
statutAtelier: orderData.statutAtelier,
montantHT: totalHT,
montantTVA: totalTVA,
montantTTC: totalTTC,
dateAtelier: new Date(),
lignes: {
create: orderData.products.map(product => ({
produitId: product.id,
quantite: 1,
prixUnitaireHT: product.prixVenteTTC / 1.2,
prixUnitaireTTC: product.prixVenteTTC,
remise: 0,
montantHT: product.prixVenteTTC / 1.2,
montantTTC: product.prixVenteTTC
}))
},
paiements: {
create: {
mode: 'CARTE',
montant: totalTTC,
reference: `TEST${createdOrders.length + 1}`
}
}
},
include: {
client: true,
lignes: {
include: {
produit: true
}
},
paiements: true
}
})
createdOrders.push(order)
}
return NextResponse.json({
message: 'Sample work order data created successfully',
count: createdOrders.length,
orders: createdOrders.map(o => ({
id: o.id,
numero: o.numero,
statutAtelier: o.statutAtelier
}))
})
} catch (error) {
console.error('Error seeding work order data:', error)
return NextResponse.json(
{ error: 'Failed to seed work order data', details: error instanceof Error ? error.message : 'Unknown error' },
{ 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,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/clients/[id]/patients - Get all patients for a client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
console.log('API: GET /api/clients/[id]/patients')
console.log('API: params.id =', id)
console.log('API: typeof params.id =', typeof id)
const patients = await db.patient.findMany({
where: { clientId: id },
include: {
ordonnances: {
include: {
fichiers: true
}
}
},
orderBy: {
dateCreation: 'desc'
}
})
console.log('API: Patients trouvés =', patients.length)
patients.forEach((p, i) => {
console.log(`API: Patient ${i}: id=${p.id}, clientId=${p.clientId}`)
})
return NextResponse.json(patients)
} catch (error) {
console.error('Error fetching patients:', error)
return NextResponse.json(
{ error: 'Failed to fetch patients' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/clients/[id] - Get a specific client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const client = await db.client.findUnique({
where: { id: id },
include: {
patients: {
include: {
ordonnances: {
include: {
fichiers: true
}
}
}
},
ventes: true
}
})
if (!client) {
return NextResponse.json(
{ error: 'Client not found' },
{ status: 404 }
)
}
return NextResponse.json(client)
} catch (error) {
console.error('Error fetching client:', error)
return NextResponse.json(
{ error: 'Failed to fetch client' },
{ status: 500 }
)
}
}
// PUT /api/clients/[id] - Update a client
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const {
nom,
prenom,
email,
telephone,
adresse,
ville,
codePostal,
dateNaissance,
notes
} = body
// Validate required fields
if (!nom || !prenom || !telephone) {
return NextResponse.json(
{ error: 'Missing required fields: nom, prenom, telephone' },
{ status: 400 }
)
}
// Check if telephone is used by another client
const existingClient = await db.client.findFirst({
where: {
telephone,
NOT: {
id: id
}
}
})
if (existingClient) {
return NextResponse.json(
{ error: 'Un autre client utilise déjà ce numéro de téléphone' },
{ status: 400 }
)
}
const client = await db.client.update({
where: { id: id },
data: {
nom,
prenom,
email: email || null,
telephone,
adresse: adresse || null,
ville: ville || null,
codePostal: codePostal || null,
dateNaissance: dateNaissance ? new Date(dateNaissance) : null,
notes: notes || null
}
})
return NextResponse.json(client)
} catch (error) {
console.error('Error updating client:', error)
return NextResponse.json(
{ error: 'Failed to update client' },
{ status: 500 }
)
}
}
// DELETE /api/clients/[id] - Delete a client
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Check if client has sales
const salesCount = await db.vente.count({
where: { clientId: id }
})
if (salesCount > 0) {
return NextResponse.json(
{ error: 'Impossible de supprimer un client qui a des ventes associées' },
{ status: 400 }
)
}
await db.client.delete({
where: { id: id }
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting client:', error)
return NextResponse.json(
{ error: 'Failed to delete client' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/clients - Get all clients
export async function GET() {
try {
const clients = await db.client.findMany({
include: {
patients: {
include: {
ordonnances: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(clients)
} catch (error) {
console.error('Error fetching clients:', error)
return NextResponse.json(
{ error: 'Failed to fetch clients' },
{ status: 500 }
)
}
}
// POST /api/clients - Create a new client
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
nom,
prenom,
email,
telephone,
adresse,
ville,
codePostal,
dateNaissance,
notes
} = body
// Validate required fields
if (!nom || !prenom || !telephone) {
return NextResponse.json(
{ error: 'Missing required fields: nom, prenom, telephone' },
{ status: 400 }
)
}
// Check if telephone already exists
const existingClient = await db.client.findUnique({
where: { telephone }
})
if (existingClient) {
return NextResponse.json(
{ error: 'Un client avec ce numéro de téléphone existe déjà' },
{ status: 400 }
)
}
const client = await db.client.create({
data: {
nom,
prenom,
email: email || null,
telephone,
adresse: adresse || null,
ville: ville || null,
codePostal: codePostal || null,
dateNaissance: dateNaissance ? new Date(dateNaissance) : null,
notes: notes || null
}
})
return NextResponse.json(client, { status: 201 })
} catch (error) {
console.error('Error creating client:', error)
return NextResponse.json(
{ error: 'Failed to create client' },
{ 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,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { unlink } from 'fs/promises'
import path from 'path'
// DELETE /api/fichiers/[id] - Delete a file
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get file info
const fichier = await db.fichier.findUnique({
where: { id: id },
})
if (!fichier) {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
)
}
// Delete from database
await db.fichier.delete({
where: { id: id },
})
// Try to delete the file from disk
try {
const filePath = path.join(process.cwd(), 'public', fichier.url)
await unlink(filePath)
} catch (error) {
// File might not exist, but that's okay
console.warn('File not found on disk:', fichier.url)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting fichier:', error)
return NextResponse.json(
{ error: 'Failed to delete file' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/fournisseurs/[id]/factures - Get all purchase invoices for a supplier
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Check if supplier exists
const supplier = await db.fournisseur.findUnique({
where: { id: id },
})
if (!supplier) {
return NextResponse.json(
{ error: 'Supplier not found' },
{ status: 404 }
)
}
// Get all invoices for this supplier
const factures = await db.factureAchat.findMany({
where: {
fournisseurId: id,
},
orderBy: {
date: 'desc',
},
})
return NextResponse.json(factures)
} catch (error) {
console.error('Error fetching supplier invoices:', error)
return NextResponse.json(
{ error: 'Failed to fetch supplier invoices' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/fournisseurs/[id] - Get a single supplier
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const supplier = await db.fournisseur.findUnique({
where: { id: id },
include: {
_count: {
select: {
produits: true,
facturesAchat: true,
},
},
},
})
if (!supplier) {
return NextResponse.json(
{ error: 'Supplier not found' },
{ status: 404 }
)
}
return NextResponse.json(supplier)
} catch (error) {
console.error('Error fetching supplier:', error)
return NextResponse.json(
{ error: 'Failed to fetch supplier' },
{ status: 500 }
)
}
}
// PUT /api/fournisseurs/[id] - Update a supplier
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
// Check if supplier exists
const existingSupplier = await db.fournisseur.findUnique({
where: { id: id },
})
if (!existingSupplier) {
return NextResponse.json(
{ error: 'Supplier not found' },
{ status: 404 }
)
}
// Validate required fields
if (!body.nom) {
return NextResponse.json(
{ error: 'Missing required field: nom' },
{ status: 400 }
)
}
const supplier = await db.fournisseur.update({
where: { id: id },
data: {
nom: body.nom,
contact: body.contact !== undefined ? body.contact : existingSupplier.contact,
email: body.email !== undefined ? body.email : existingSupplier.email,
telephone: body.telephone !== undefined ? body.telephone : existingSupplier.telephone,
adresse: body.adresse !== undefined ? body.adresse : existingSupplier.adresse,
ville: body.ville !== undefined ? body.ville : existingSupplier.ville,
codePostal: body.codePostal !== undefined ? body.codePostal : existingSupplier.codePostal,
notes: body.notes !== undefined ? body.notes : existingSupplier.notes,
actif: body.actif !== undefined ? body.actif : existingSupplier.actif,
},
})
return NextResponse.json(supplier)
} catch (error) {
console.error('Error updating supplier:', error)
return NextResponse.json(
{ error: 'Failed to update supplier' },
{ status: 500 }
)
}
}
// DELETE /api/fournisseurs/[id] - Delete a supplier
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Check if supplier exists
const existingSupplier = await db.fournisseur.findUnique({
where: { id: id },
include: {
_count: {
select: {
produits: true,
facturesAchat: true,
},
},
},
})
if (!existingSupplier) {
return NextResponse.json(
{ error: 'Supplier not found' },
{ status: 404 }
)
}
// Check if supplier has associated products or invoices
if (existingSupplier._count.produits > 0) {
return NextResponse.json(
{ error: 'Cannot delete supplier with associated products. Please remove or reassign products first.' },
{ status: 400 }
)
}
if (existingSupplier._count.facturesAchat > 0) {
return NextResponse.json(
{ error: 'Cannot delete supplier with associated purchase invoices. Please archive the supplier instead.' },
{ status: 400 }
)
}
// Delete the supplier
await db.fournisseur.delete({
where: { id: id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting supplier:', error)
return NextResponse.json(
{ error: 'Failed to delete supplier' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/fournisseurs - Get all suppliers
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const actif = searchParams.get('actif')
const where: any = {}
if (actif !== null && actif !== 'all') {
where.actif = actif === 'true'
}
const fournisseurs = await db.fournisseur.findMany({
where,
orderBy: {
nom: 'asc',
},
include: {
_count: {
select: {
produits: true,
facturesAchat: true,
},
},
},
})
return NextResponse.json(fournisseurs)
} catch (error) {
console.error('Error fetching fournisseurs:', error)
return NextResponse.json(
{ error: 'Failed to fetch suppliers' },
{ status: 500 }
)
}
}
// POST /api/fournisseurs - Create a new supplier
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.nom) {
return NextResponse.json(
{ error: 'Missing required field: nom' },
{ status: 400 }
)
}
const fournisseur = await db.fournisseur.create({
data: {
nom: body.nom,
contact: body.contact || null,
email: body.email || null,
telephone: body.telephone || null,
adresse: body.adresse || null,
ville: body.ville || null,
codePostal: body.codePostal || null,
notes: body.notes || null,
actif: body.actif !== undefined ? body.actif : true,
},
})
return NextResponse.json(fournisseur)
} catch (error) {
console.error('Error creating fournisseur:', error)
return NextResponse.json(
{ error: 'Failed to create supplier' },
{ 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

@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/ordonnances/[id] - Get a specific ordonnance
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const ordonnance = await db.ordonnance.findUnique({
where: { id: id },
include: {
patient: {
include: {
client: true
}
},
fichiers: true
}
})
if (!ordonnance) {
return NextResponse.json(
{ error: 'Ordonnance not found' },
{ status: 404 }
)
}
return NextResponse.json(ordonnance)
} catch (error) {
console.error('Error fetching ordonnance:', error)
return NextResponse.json(
{ error: 'Failed to fetch ordonnance' },
{ status: 500 }
)
}
}
// PUT /api/ordonnances/[id] - Update an ordonnance
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const {
patientId,
numero,
dateEmission,
medecin,
notes
} = body
// Check if numero is used by another ordonnance
if (numero) {
const existingOrdonnance = await db.ordonnance.findFirst({
where: {
numero,
NOT: {
id: id
}
}
})
if (existingOrdonnance) {
return NextResponse.json(
{ error: 'Une autre ordonnance utilise déjà ce numéro' },
{ status: 400 }
)
}
}
const ordonnance = await db.ordonnance.update({
where: { id: id },
data: {
patientId: patientId || undefined,
numero: numero || undefined,
dateEmission: dateEmission ? new Date(dateEmission) : undefined,
medecin: medecin !== undefined ? medecin : undefined,
notes: notes !== undefined ? notes : undefined
},
include: {
patient: {
include: {
client: true
}
}
}
})
return NextResponse.json(ordonnance)
} catch (error) {
console.error('Error updating ordonnance:', error)
return NextResponse.json(
{ error: 'Failed to update ordonnance' },
{ status: 500 }
)
}
}
// DELETE /api/ordonnances/[id] - Delete an ordonnance
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await db.ordonnance.delete({
where: { id: id }
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting ordonnance:', error)
return NextResponse.json(
{ error: 'Failed to delete ordonnance' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/ordonnances - Get all ordonnances
export async function GET() {
try {
const ordonnances = await db.ordonnance.findMany({
include: {
patient: {
include: {
client: true
}
},
fichiers: true
},
orderBy: {
dateEmission: 'desc'
}
})
return NextResponse.json(ordonnances)
} catch (error) {
console.error('Error fetching ordonnances:', error)
return NextResponse.json(
{ error: 'Failed to fetch ordonnances' },
{ status: 500 }
)
}
}
// POST /api/ordonnances - Create a new ordonnance
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
patientId,
numero,
dateEmission,
medecin,
notes
} = body
// Validate required fields
if (!patientId || !numero || !dateEmission) {
return NextResponse.json(
{ error: 'Missing required fields: patientId, numero, dateEmission' },
{ status: 400 }
)
}
// Check if patient exists
const patient = await db.patient.findUnique({
where: { id: patientId }
})
if (!patient) {
return NextResponse.json(
{ error: 'Patient not found' },
{ status: 404 }
)
}
// Check if numero already exists
const existingOrdonnance = await db.ordonnance.findUnique({
where: { numero }
})
if (existingOrdonnance) {
return NextResponse.json(
{ error: 'Une ordonnance avec ce numéro existe déjà' },
{ status: 400 }
)
}
const ordonnance = await db.ordonnance.create({
data: {
patientId,
numero,
dateEmission: new Date(dateEmission),
medecin: medecin || null,
notes: notes || null
},
include: {
patient: {
include: {
client: true
}
}
}
})
return NextResponse.json(ordonnance, { status: 201 })
} catch (error) {
console.error('Error creating ordonnance:', error)
return NextResponse.json(
{ error: 'Failed to create ordonnance' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/patients/[id]/ordonnances - Get all ordonnances for a patient
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const ordonnances = await db.ordonnance.findMany({
where: { patientId: id },
include: {
fichiers: true,
patient: {
include: {
client: true
}
}
},
orderBy: {
dateEmission: 'desc'
}
})
return NextResponse.json(ordonnances)
} catch (error) {
console.error('Error fetching ordonnances:', error)
return NextResponse.json(
{ error: 'Failed to fetch ordonnances' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/patients/[id] - Get a specific patient
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const patient = await db.patient.findUnique({
where: { id },
include: {
client: true,
ordonnances: {
include: {
fichiers: true
}
}
}
})
if (!patient) {
return NextResponse.json(
{ error: 'Patient not found' },
{ status: 404 }
)
}
return NextResponse.json(patient)
} catch (error) {
console.error('Error fetching patient:', error)
return NextResponse.json(
{ error: 'Failed to fetch patient' },
{ status: 500 }
)
}
}
// PUT /api/patients/[id] - Update a patient
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const {
clientId,
odSphere,
odCylindre,
odAxe,
ogSphere,
ogCylindre,
ogAxe,
addition,
pd,
hauteur,
notes
} = body
const patient = await db.patient.update({
where: { id },
data: {
clientId: clientId || undefined,
odSphere: odSphere !== undefined ? parseFloat(odSphere) : undefined,
odCylindre: odCylindre !== undefined ? parseFloat(odCylindre) : undefined,
odAxe: odAxe !== undefined ? parseInt(odAxe) : undefined,
ogSphere: ogSphere !== undefined ? parseFloat(ogSphere) : undefined,
ogCylindre: ogCylindre !== undefined ? parseFloat(ogCylindre) : undefined,
ogAxe: ogAxe !== undefined ? parseInt(ogAxe) : undefined,
addition: addition !== undefined ? parseFloat(addition) : undefined,
pd: pd !== undefined ? parseFloat(pd) : undefined,
hauteur: hauteur !== undefined ? parseFloat(hauteur) : undefined,
notes: notes !== undefined ? notes : undefined
},
include: {
client: true,
ordonnances: true
}
})
return NextResponse.json(patient)
} catch (error) {
console.error('Error updating patient:', error)
return NextResponse.json(
{ error: 'Failed to update patient' },
{ status: 500 }
)
}
}
// DELETE /api/patients/[id] - Delete a patient
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await db.patient.delete({
where: { id }
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting patient:', error)
return NextResponse.json(
{ error: 'Failed to delete patient' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/patients - Get all patients
export async function GET() {
try {
const patients = await db.patient.findMany({
include: {
client: true,
ordonnances: true
},
orderBy: {
dateCreation: 'desc'
}
})
return NextResponse.json(patients)
} catch (error) {
console.error('Error fetching patients:', error)
return NextResponse.json(
{ error: 'Failed to fetch patients' },
{ status: 500 }
)
}
}
// POST /api/patients - Create a new patient
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
clientId,
odSphere,
odCylindre,
odAxe,
ogSphere,
ogCylindre,
ogAxe,
addition,
pd,
hauteur,
notes
} = body
// Validate required field
if (!clientId) {
return NextResponse.json(
{ error: 'Missing required field: clientId' },
{ status: 400 }
)
}
// Check if client exists
const client = await db.client.findUnique({
where: { id: clientId }
})
if (!client) {
return NextResponse.json(
{ error: 'Client not found' },
{ status: 404 }
)
}
const patient = await db.patient.create({
data: {
clientId,
odSphere: odSphere !== undefined ? parseFloat(odSphere) : null,
odCylindre: odCylindre !== undefined ? parseFloat(odCylindre) : null,
odAxe: odAxe !== undefined ? parseInt(odAxe) : null,
ogSphere: ogSphere !== undefined ? parseFloat(ogSphere) : null,
ogCylindre: ogCylindre !== undefined ? parseFloat(ogCylindre) : null,
ogAxe: ogAxe !== undefined ? parseInt(ogAxe) : null,
addition: addition !== undefined ? parseFloat(addition) : null,
pd: pd !== undefined ? parseFloat(pd) : null,
hauteur: hauteur !== undefined ? parseFloat(hauteur) : null,
notes: notes || null
},
include: {
client: true,
ordonnances: true
}
})
return NextResponse.json(patient, { status: 201 })
} catch (error) {
console.error('Error creating patient:', error)
return NextResponse.json(
{ error: 'Failed to create patient' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/pos/clients - Get all clients
export async function GET(request: NextRequest) {
try {
const clients = await db.client.findMany({
orderBy: {
nom: 'asc'
}
})
return NextResponse.json(clients)
} catch (error) {
console.error('Error fetching clients:', error)
return NextResponse.json(
{ error: 'Failed to fetch clients' },
{ status: 500 }
)
}
}
// POST /api/pos/clients - Create a new client
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { nom, prenom, email, telephone } = body
// Validate required fields
if (!nom || !prenom || !telephone) {
return NextResponse.json(
{ error: 'Missing required fields: nom, prenom, telephone' },
{ status: 400 }
)
}
// Check if telephone already exists
const existingClient = await db.client.findUnique({
where: { telephone }
})
if (existingClient) {
return NextResponse.json(
{ error: 'Un client avec ce numéro de téléphone existe déjà' },
{ status: 400 }
)
}
// Create client
const client = await db.client.create({
data: {
nom,
prenom,
email: email || null,
telephone
}
})
return NextResponse.json(client, { status: 201 })
} catch (error) {
console.error('Error creating client:', error)
return NextResponse.json(
{ error: 'Failed to create client' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/pos/products - Get all active products
export async function GET(request: NextRequest) {
try {
const products = await db.produit.findMany({
where: {
actif: true,
stock: {
gt: 0
}
},
orderBy: {
designation: 'asc'
}
})
return NextResponse.json(products)
} catch (error) {
console.error('Error fetching products:', error)
return NextResponse.json(
{ error: 'Failed to fetch products' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutVente } from '@prisma/client'
// GET /api/pos/sales/[id] - Get a specific sale
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const sale = await db.vente.findUnique({
where: { id: id },
include: {
client: true,
employe: true,
lignes: {
include: {
produit: true
}
},
paiements: true,
fichiers: true
}
})
if (!sale) {
return NextResponse.json(
{ error: 'Sale not found' },
{ status: 404 }
)
}
return NextResponse.json(sale)
} catch (error) {
console.error('Error fetching sale:', error)
return NextResponse.json(
{ error: 'Failed to fetch sale' },
{ status: 500 }
)
}
}
// PATCH /api/pos/sales/[id] - Update sale status
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const { statut, notes } = body
// Validate status
if (statut && !Object.values(StatutVente).includes(statut)) {
return NextResponse.json(
{ error: 'Invalid status value' },
{ status: 400 }
)
}
// Check if sale exists
const existingSale = await db.vente.findUnique({
where: { id: id }
})
if (!existingSale) {
return NextResponse.json(
{ error: 'Sale not found' },
{ status: 404 }
)
}
// Update sale
const updatedSale = await db.vente.update({
where: { id: id },
data: {
...(statut && { statut }),
...(notes !== undefined && { notes })
},
include: {
client: true,
lignes: {
include: {
produit: true
}
},
paiements: true
}
})
return NextResponse.json(updatedSale)
} catch (error) {
console.error('Error updating sale:', error)
return NextResponse.json(
{ error: 'Failed to update sale' },
{ status: 500 }
)
}
}
// DELETE /api/pos/sales/[id] - Cancel/Delete a sale
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Check if sale exists
const existingSale = await db.vente.findUnique({
where: { id: id },
include: {
lignes: true
}
})
if (!existingSale) {
return NextResponse.json(
{ error: 'Sale not found' },
{ status: 404 }
)
}
// If sale is already paid, we can't delete it, only cancel
if (existingSale.statut === StatutVente.PAYEE) {
// Update status to ANNULEE
const cancelledSale = await db.vente.update({
where: { id: id },
data: {
statut: StatutVente.ANNULEE
}
})
return NextResponse.json(cancelledSale)
}
// If sale is not paid, we can delete it and restore stock
await db.$transaction(async (tx) => {
// Restore product stock
for (const ligne of existingSale.lignes) {
await tx.produit.update({
where: { id: ligne.produitId },
data: {
stock: {
increment: ligne.quantite
}
}
})
}
// Delete sale (cascade will delete lines and payments)
await tx.vente.delete({
where: { id: id }
})
})
return NextResponse.json({ message: 'Sale deleted successfully' })
} catch (error) {
console.error('Error deleting sale:', error)
return NextResponse.json(
{ error: 'Failed to delete sale' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,199 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutVente, ModePaiement } from '@prisma/client'
import { getCurrentUserId, requireAuth } from '@/lib/auth-utils'
// Helper function to generate sale number
async function generateSaleNumber(): Promise<string> {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
// Count sales for this month
const salesThisMonth = await db.vente.count({
where: {
date: {
gte: new Date(year, today.getMonth(), 1),
lt: new Date(year, today.getMonth() + 1, 1)
}
}
})
const sequence = String(salesThisMonth + 1).padStart(4, '0')
return `V${year}${month}${sequence}`
}
// GET /api/pos/sales - Get all sales with relations
export async function GET(request: NextRequest) {
try {
const sales = await db.vente.findMany({
include: {
client: true,
employe: true,
lignes: {
include: {
produit: true
}
},
paiements: true
},
orderBy: {
date: 'desc'
},
take: 50 // Limit to last 50 sales
})
return NextResponse.json(sales)
} catch (error) {
console.error('Error fetching sales:', error)
return NextResponse.json(
{ error: 'Failed to fetch sales' },
{ status: 500 }
)
}
}
// POST /api/pos/sales - Create a new sale
export async function POST(request: NextRequest) {
try {
const authError = await requireAuth()
if (authError) return authError
const body = await request.json()
const {
clientId,
lignes,
paiements,
remise,
montantHT,
montantTVA,
montantTTC,
notes
} = body
// Validate required fields
if (!lignes || lignes.length === 0) {
return NextResponse.json(
{ error: 'Sale must have at least one line' },
{ status: 400 }
)
}
if (!paiements || paiements.length === 0) {
return NextResponse.json(
{ error: 'Sale must have at least one payment' },
{ status: 400 }
)
}
// Verify product availability
for (const ligne of lignes) {
const product = await db.produit.findUnique({
where: { id: ligne.produitId }
})
if (!product) {
return NextResponse.json(
{ error: `Product ${ligne.produitId} not found` },
{ status: 400 }
)
}
if (product.stock < ligne.quantite) {
return NextResponse.json(
{ error: `Insufficient stock for product ${product.designation}` },
{ status: 400 }
)
}
}
// Get current user
const employeId = await getCurrentUserId()
// Generate sale number
const numero = await generateSaleNumber()
// Create sale with transaction
const sale = await db.$transaction(async (tx) => {
// Create sale
const vente = await tx.vente.create({
data: {
numero,
clientId: clientId || null,
statut: StatutVente.PAYEE,
montantHT,
montantTVA,
montantTTC,
remise: remise || 0,
notes: notes || null,
employeId: null // Can be updated with authentication
}
})
// Create sale lines and update product stock
for (const ligne of lignes) {
await tx.ligneVente.create({
data: {
venteId: vente.id,
produitId: ligne.produitId,
quantite: ligne.quantite,
prixUnitaireHT: ligne.prixUnitaireHT,
prixUnitaireTTC: ligne.prixUnitaireTTC,
remise: ligne.remise || 0,
montantHT: ligne.montantHT,
montantTTC: ligne.montantTTC
}
})
// Update product stock
await tx.produit.update({
where: { id: ligne.produitId },
data: {
stock: {
decrement: ligne.quantite
}
}
})
}
// Create payments
for (const paiement of paiements) {
await tx.paiement.create({
data: {
venteId: vente.id,
mode: paiement.mode as ModePaiement,
montant: paiement.montant,
reference: paiement.reference || null,
notes: paiement.notes || null,
employeId
}
})
}
return vente
})
// Fetch the complete sale with relations
const completeSale = await db.vente.findUnique({
where: { id: sale.id },
include: {
client: true,
employe: true,
lignes: {
include: {
produit: true
}
},
paiements: true
}
})
return NextResponse.json(completeSale, { status: 201 })
} catch (error) {
console.error('Error creating sale:', error)
return NextResponse.json(
{ error: 'Failed to create sale' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,218 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
// POST /api/pos/seed - Seed sample data for testing
export async function POST() {
try {
// Create sample clients
const clients = await Promise.all([
db.client.create({
data: {
nom: 'Dupont',
prenom: 'Jean',
email: 'jean.dupont@email.com',
telephone: '0612345678',
ville: 'Paris'
}
}),
db.client.create({
data: {
nom: 'Martin',
prenom: 'Marie',
email: 'marie.martin@email.com',
telephone: '0623456789',
ville: 'Lyon'
}
}),
db.client.create({
data: {
nom: 'Bernard',
prenom: 'Pierre',
telephone: '0634567890',
ville: 'Marseille'
}
})
])
// Create sample products
const products = await Promise.all([
// Montures
db.produit.create({
data: {
reference: 'MNT001',
designation: 'Monture Ray-Ban Aviator',
categorie: 'MONTURE',
prixAchatHT: 80,
prixVenteTTC: 150,
tva: 20,
stock: 15,
stockMin: 5,
marque: 'Ray-Ban',
typeMonture: 'COMPLET',
materiau: 'Métal',
couleur: 'Or',
emplacement: 'A1-01'
}
}),
db.produit.create({
data: {
reference: 'MNT002',
designation: 'Monture Oakley Holbrook',
categorie: 'MONTURE',
prixAchatHT: 60,
prixVenteTTC: 120,
tva: 20,
stock: 20,
stockMin: 5,
marque: 'Oakley',
typeMonture: 'COMPLET',
materiau: 'Acétate',
couleur: 'Noir',
emplacement: 'A1-02'
}
}),
db.produit.create({
data: {
reference: 'MNT003',
designation: 'Monture Vogue VO5352S',
categorie: 'MONTURE',
prixAchatHT: 45,
prixVenteTTC: 95,
tva: 20,
stock: 25,
stockMin: 5,
marque: 'Vogue',
typeMonture: 'NATUREL',
materiau: 'Acétate',
couleur: 'Rouge',
emplacement: 'A1-03'
}
}),
// Verres
db.produit.create({
data: {
reference: 'VRG001',
designation: 'Verre Simple Vision 1.5',
categorie: 'VERRE',
prixAchatHT: 15,
prixVenteTTC: 45,
tva: 20,
stock: 100,
stockMin: 20,
typeVerre: 'SIMPLE',
indice: 1.5,
emplacement: 'B1-01'
}
}),
db.produit.create({
data: {
reference: 'VRG002',
designation: 'Verre Bifocal 1.5',
categorie: 'VERRE',
prixAchatHT: 30,
prixVenteTTC: 75,
tva: 20,
stock: 50,
stockMin: 10,
typeVerre: 'BIFOCAL',
indice: 1.5,
emplacement: 'B1-02'
}
}),
db.produit.create({
data: {
reference: 'VRG003',
designation: 'Verre Progressif 1.6',
categorie: 'VERRE',
prixAchatHT: 60,
prixVenteTTC: 150,
tva: 20,
stock: 30,
stockMin: 10,
typeVerre: 'PROGRESSIF',
indice: 1.6,
emplacement: 'B1-03'
}
}),
// Lentilles
db.produit.create({
data: {
reference: 'LEN001',
designation: 'Lentilles Journalières',
categorie: 'LENTILLE',
prixAchatHT: 0.20,
prixVenteTTC: 0.50,
tva: 20,
stock: 500,
stockMin: 100,
emplacement: 'C1-01'
}
}),
db.produit.create({
data: {
reference: 'LEN002',
designation: 'Lentilles Mensuelles',
categorie: 'LENTILLE',
prixAchatHT: 8,
prixVenteTTC: 20,
tva: 20,
stock: 100,
stockMin: 20,
emplacement: 'C1-02'
}
}),
// Accessoires
db.produit.create({
data: {
reference: 'ACC001',
designation: 'Étui Lunettes Luxe',
categorie: 'ACCESSOIRE',
prixAchatHT: 10,
prixVenteTTC: 25,
tva: 20,
stock: 40,
stockMin: 10,
emplacement: 'D1-01'
}
}),
db.produit.create({
data: {
reference: 'ACC002',
designation: 'Kit Nettoyage',
categorie: 'ACCESSOIRE',
prixAchatHT: 3,
prixVenteTTC: 8,
tva: 20,
stock: 80,
stockMin: 20,
emplacement: 'D1-02'
}
}),
db.produit.create({
data: {
reference: 'ACC003',
designation: 'Chaînette Lunettes',
categorie: 'ACCESSOIRE',
prixAchatHT: 2,
prixVenteTTC: 6,
tva: 20,
stock: 60,
stockMin: 15,
emplacement: 'D1-03'
}
})
])
return NextResponse.json({
message: 'Sample data seeded successfully',
clients: clients.length,
products: products.length
})
} catch (error) {
console.error('Error seeding data:', error)
return NextResponse.json(
{ error: 'Failed to seed data' },
{ status: 500 }
)
}
}

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

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/produits/[id]/images - Get all images for a product
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const images = await db.fichier.findMany({
where: {
produitId: id,
type: 'IMAGE_PRODUIT',
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json(images)
} catch (error) {
console.error('Error fetching product images:', error)
return NextResponse.json(
{ error: 'Failed to fetch images' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,244 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/produits/[id] - Get a single product
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const produit = await db.produit.findUnique({
where: { id: id },
include: {
fournisseur: {
select: {
id: true,
nom: true,
},
},
fichiers: {
where: {
type: 'IMAGE_PRODUIT',
},
},
},
})
if (!produit) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
)
}
return NextResponse.json({
...produit,
fournisseurNom: produit.fournisseur?.nom || null,
fournisseurId: produit.fournisseur?.id || null,
images: produit.fichiers || [],
})
} catch (error) {
console.error('Error fetching produit:', error)
return NextResponse.json(
{ error: 'Failed to fetch product' },
{ status: 500 }
)
}
}
// PUT /api/produits/[id] - Update a product (full update)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
// Check if product exists
const existingProduit = await db.produit.findUnique({
where: { id: id },
})
if (!existingProduit) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
)
}
// Check if reference is being changed and if it already exists
if (body.reference && body.reference !== existingProduit.reference) {
const referenceExists = await db.produit.findUnique({
where: { reference: body.reference },
})
if (referenceExists) {
return NextResponse.json(
{ error: 'Un produit avec cette référence existe déjà' },
{ status: 400 }
)
}
}
// Check if barcode is being changed and if it already exists
if (body.codeBarre && body.codeBarre !== existingProduit.codeBarre) {
const barcodeExists = await db.produit.findUnique({
where: { codeBarre: body.codeBarre },
})
if (barcodeExists) {
return NextResponse.json(
{ error: 'Un produit avec ce code-barres existe déjà' },
{ status: 400 }
)
}
}
const produit = await db.produit.update({
where: { id: id },
data: {
...(body.reference && { reference: body.reference }),
...(body.designation && { designation: body.designation }),
...(body.categorie && { categorie: body.categorie }),
...(body.fournisseurId !== undefined && { fournisseurId: body.fournisseurId || null }),
...(body.prixAchatHT !== undefined && { prixAchatHT: parseFloat(body.prixAchatHT) }),
...(body.prixVenteTTC !== undefined && { prixVenteTTC: parseFloat(body.prixVenteTTC) }),
...(body.tva !== undefined && { tva: parseFloat(body.tva) }),
...(body.stock !== undefined && { stock: parseInt(body.stock) }),
...(body.stockMin !== undefined && { stockMin: parseInt(body.stockMin) }),
...(body.emplacement !== undefined && { emplacement: body.emplacement || null }),
...(body.marque !== undefined && { marque: body.marque || null }),
...(body.typeMonture !== undefined && { typeMonture: body.typeMonture || null }),
...(body.typeVerre !== undefined && { typeVerre: body.typeVerre || null }),
...(body.indice !== undefined && { indice: body.indice ? parseFloat(body.indice) : null }),
...(body.materiau !== undefined && { materiau: body.materiau || null }),
...(body.couleur !== undefined && { couleur: body.couleur || null }),
...(body.dimensions !== undefined && { dimensions: body.dimensions || null }),
...(body.description !== undefined && { description: body.description || null }),
...(body.codeBarre !== undefined && { codeBarre: body.codeBarre || null }),
...(body.actif !== undefined && { actif: body.actif }),
},
include: {
fournisseur: {
select: {
id: true,
nom: true,
},
},
},
})
return NextResponse.json({
...produit,
fournisseurNom: produit.fournisseur?.nom || null,
fournisseurId: produit.fournisseur?.id || null,
})
} catch (error) {
console.error('Error updating produit:', error)
return NextResponse.json(
{ error: 'Failed to update product' },
{ status: 500 }
)
}
}
// PATCH /api/produits/[id] - Partial update of a product
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
// Check if product exists
const existingProduit = await db.produit.findUnique({
where: { id: id },
})
if (!existingProduit) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
)
}
const produit = await db.produit.update({
where: { id: id },
data: body,
include: {
fournisseur: {
select: {
id: true,
nom: true,
},
},
},
})
return NextResponse.json({
...produit,
fournisseurNom: produit.fournisseur?.nom || null,
fournisseurId: produit.fournisseur?.id || null,
})
} catch (error) {
console.error('Error patching produit:', error)
return NextResponse.json(
{ error: 'Failed to update product' },
{ status: 500 }
)
}
}
// DELETE /api/produits/[id] - Delete a product
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Check if product exists
const existingProduit = await db.produit.findUnique({
where: { id: id },
include: {
ligneVente: true,
ligneFacture: true,
},
})
if (!existingProduit) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
)
}
// Check if product is used in sales or purchases
if (existingProduit.ligneVente.length > 0 || existingProduit.ligneFacture.length > 0) {
return NextResponse.json(
{ error: 'Ce produit est utilisé dans des ventes ou des achats et ne peut pas être supprimé' },
{ status: 400 }
)
}
// Delete associated files first
await db.fichier.deleteMany({
where: { produitId: id },
})
// Delete the product
await db.produit.delete({
where: { id: id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting produit:', error)
return NextResponse.json(
{ error: 'Failed to delete product' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
// GET /api/produits - Get all products with filters
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const search = searchParams.get('search')
const categorie = searchParams.get('categorie')
const stockStatus = searchParams.get('stockStatus')
const actif = searchParams.get('actif')
const where: any = {}
if (search) {
where.OR = [
{ reference: { contains: search, mode: 'insensitive' } },
{ designation: { contains: search, mode: 'insensitive' } },
{ marque: { contains: search, mode: 'insensitive' } },
{ codeBarre: { contains: search, mode: 'insensitive' } },
]
}
if (categorie && categorie !== 'all') {
where.categorie = categorie
}
if (actif !== null && actif !== 'all') {
where.actif = actif === 'true'
}
let produits = await db.produit.findMany({
where,
include: {
fournisseur: {
select: {
id: true,
nom: true,
},
},
fichiers: {
where: {
type: 'IMAGE_PRODUIT',
},
},
},
orderBy: {
createdAt: 'desc',
},
})
// Apply stock status filter (client-side for complex logic)
if (stockStatus && stockStatus !== 'all') {
produits = produits.filter((produit) => {
if (stockStatus === 'low') return produit.stock < produit.stockMin
if (stockStatus === 'ok') return produit.stock >= produit.stockMin
if (stockStatus === 'out') return produit.stock <= 0
return true
})
}
// Transform data to match frontend expectations
const transformedProduits = produits.map((produit) => ({
...produit,
fournisseurNom: produit.fournisseur?.nom || null,
fournisseurId: produit.fournisseur?.id || null,
images: produit.fichiers || [],
}))
return NextResponse.json(transformedProduits)
} catch (error) {
console.error('Error fetching produits:', error)
return NextResponse.json(
{ error: 'Failed to fetch products' },
{ status: 500 }
)
}
}
// POST /api/produits - Create a new product
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.reference || !body.designation || !body.categorie) {
return NextResponse.json(
{ error: 'Missing required fields: reference, designation, categorie' },
{ status: 400 }
)
}
// Check if reference already exists
const existingProduit = await db.produit.findUnique({
where: { reference: body.reference },
})
if (existingProduit) {
return NextResponse.json(
{ error: 'Un produit avec cette référence existe déjà' },
{ status: 400 }
)
}
// Check if barcode already exists (if provided)
if (body.codeBarre) {
const existingBarcode = await db.produit.findUnique({
where: { codeBarre: body.codeBarre },
})
if (existingBarcode) {
return NextResponse.json(
{ error: 'Un produit avec ce code-barres existe déjà' },
{ status: 400 }
)
}
}
const produit = await db.produit.create({
data: {
reference: body.reference,
designation: body.designation,
categorie: body.categorie,
fournisseurId: body.fournisseurId || null,
prixAchatHT: parseFloat(body.prixAchatHT) || 0,
prixVenteTTC: parseFloat(body.prixVenteTTC) || 0,
tva: parseFloat(body.tva) || 20,
stock: parseInt(body.stock) || 0,
stockMin: parseInt(body.stockMin) || 5,
emplacement: body.emplacement || null,
marque: body.marque || null,
typeMonture: body.typeMonture || null,
typeVerre: body.typeVerre || null,
indice: body.indice ? parseFloat(body.indice) : null,
materiau: body.materiau || null,
couleur: body.couleur || null,
dimensions: body.dimensions || null,
description: body.description || null,
codeBarre: body.codeBarre || null,
actif: body.actif !== undefined ? body.actif : true,
},
include: {
fournisseur: {
select: {
id: true,
nom: true,
},
},
},
})
return NextResponse.json({
...produit,
fournisseurNom: produit.fournisseur?.nom || null,
fournisseurId: produit.fournisseur?.id || null,
})
} catch (error) {
console.error('Error creating produit:', error)
return NextResponse.json(
{ error: 'Failed to create product' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
// POST /api/produits/upload-image - Upload an image for a product
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
const produitId = formData.get('produitId') as string
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
)
}
if (!produitId) {
return NextResponse.json(
{ error: 'No product ID provided' },
{ status: 400 }
)
}
// Check if product exists
const produit = await db.produit.findUnique({
where: { id: produitId },
})
if (!produit) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
)
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only JPEG, PNG, WebP, and GIF are allowed' },
{ status: 400 }
)
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 5MB' },
{ status: 400 }
)
}
// Read file buffer
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Generate unique filename
const fileExtension = path.extname(file.name)
const uniqueFileName = `${uuidv4()}${fileExtension}`
// Create uploads directory if it doesn't exist
const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'products')
await mkdir(uploadDir, { recursive: true })
// Write file to disk
const filePath = path.join(uploadDir, uniqueFileName)
await writeFile(filePath, buffer)
// Save file info to database
const fichier = await db.fichier.create({
data: {
nom: file.name,
type: 'IMAGE_PRODUIT',
url: `/uploads/products/${uniqueFileName}`,
taille: file.size,
mimeType: file.type,
produitId: produitId,
},
})
return NextResponse.json(fichier)
} catch (error) {
console.error('Error uploading image:', error)
return NextResponse.json(
{ error: 'Failed to upload image' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfYear, endOfYear } from 'date-fns'
export async function GET(request: NextRequest) {
try {
const now = new Date()
// Time ranges
const todayStart = startOfDay(now)
const todayEnd = endOfDay(now)
const weekStart = startOfWeek(now, { weekStartsOn: 1 })
const weekEnd = endOfWeek(now, { weekStartsOn: 1 })
const monthStart = startOfMonth(now)
const monthEnd = endOfMonth(now)
const yearStart = startOfYear(now)
const yearEnd = endOfYear(now)
// Total sales count
const [salesToday, salesWeek, salesMonth, salesYear] = await Promise.all([
db.vente.count({
where: {
date: { gte: todayStart, lte: todayEnd },
statut: 'PAYEE'
}
}),
db.vente.count({
where: {
date: { gte: weekStart, lte: weekEnd },
statut: 'PAYEE'
}
}),
db.vente.count({
where: {
date: { gte: monthStart, lte: monthEnd },
statut: 'PAYEE'
}
}),
db.vente.count({
where: {
date: { gte: yearStart, lte: yearEnd },
statut: 'PAYEE'
}
})
])
// Revenue calculations
const [revenueToday, revenueMonth] = await Promise.all([
db.vente.aggregate({
where: {
date: { gte: todayStart, lte: todayEnd },
statut: 'PAYEE'
},
_sum: {
montantHT: true,
montantTTC: true
}
}),
db.vente.aggregate({
where: {
date: { gte: monthStart, lte: monthEnd },
statut: 'PAYEE'
},
_sum: {
montantHT: true,
montantTTC: true
}
})
])
// Total clients
const totalClients = await db.client.count()
// Top selling products this month
const topProducts = await db.ligneVente.groupBy({
by: ['produitId'],
where: {
vente: {
date: { gte: monthStart, lte: monthEnd },
statut: 'PAYEE'
}
},
_sum: {
quantite: true,
montantTTC: true
},
orderBy: {
_sum: {
quantite: 'desc'
}
},
take: 5
})
// Get product details for top products
const topProductsWithDetails = await Promise.all(
topProducts.map(async (item) => {
const product = await db.produit.findUnique({
where: { id: item.produitId }
})
return {
designation: product?.designation || 'Inconnu',
quantity: item._sum.quantite || 0,
revenue: item._sum.montantTTC || 0
}
})
)
// Low stock items
const lowStockItems = await db.produit.findMany({
where: {
actif: true,
stock: {
lt: db.produit.fields.stockMin
}
},
select: {
id: true,
reference: true,
designation: true,
stock: true,
stockMin: true
},
orderBy: {
stock: 'asc'
},
take: 10
})
// Pending workshop orders (EN_ATTENTE)
const pendingWorkshopOrders = await db.vente.count({
where: {
statut: 'PAYEE',
statutAtelier: 'EN_ATTENTE'
}
})
const dashboardData = {
totalSales: {
today: salesToday,
week: salesWeek,
month: salesMonth,
year: salesYear
},
revenue: {
htToday: revenueToday._sum.montantHT || 0,
ttcToday: revenueToday._sum.montantTTC || 0,
htMonth: revenueMonth._sum.montantHT || 0,
ttcMonth: revenueMonth._sum.montantTTC || 0
},
totalClients,
topProducts: topProductsWithDetails,
lowStockItems,
pendingWorkshopOrders
}
return NextResponse.json(dashboardData)
} catch (error) {
console.error('Error fetching dashboard data:', error)
return NextResponse.json(
{ error: 'Failed to fetch dashboard data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const products = await db.produit.findMany({
where: {
actif: true
},
include: {
fournisseur: {
select: {
nom: true
}
}
},
orderBy: {
categorie: 'asc'
}
})
const headers = [
'Référence',
'Désignation',
'Catégorie',
'Marque',
'Fournisseur',
'Stock actuel',
'Stock minimum',
'Prix achat HT',
'Prix vente TTC',
'TVA %',
'Valeur stock HT',
'Emplacement',
'Code barre',
'Statut stock'
]
const rows = products.map((product) => {
const stockValue = product.stock * product.prixAchatHT
const stockStatus = product.stock < product.stockMin ? 'FAIBLE' : 'OK'
return [
product.reference,
`"${product.designation}"`,
product.categorie,
product.marque || '',
product.fournisseur?.nom || '',
product.stock.toString(),
product.stockMin.toString(),
product.prixAchatHT.toFixed(2),
product.prixVenteTTC.toFixed(2),
product.tva.toFixed(2),
stockValue.toFixed(2),
product.emplacement || '',
product.codeBarre || '',
stockStatus
].join(',')
})
const csvContent = [headers.join(','), ...rows].join('\n')
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="inventaire.csv"'
}
})
} catch (error) {
console.error('Error exporting inventory data:', error)
return NextResponse.json(
{ error: 'Failed to export inventory data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const lowStockItems = await db.produit.findMany({
where: {
actif: true,
stock: {
lt: db.produit.fields.stockMin
}
},
include: {
fournisseur: {
select: {
nom: true
}
}
},
orderBy: {
stock: 'asc'
}
})
const headers = [
'Référence',
'Désignation',
'Catégorie',
'Marque',
'Fournisseur',
'Stock actuel',
'Stock minimum',
'Déficit',
'Prix achat HT',
'Valeur à commander',
'Emplacement',
'Code barre'
]
const rows = lowStockItems.map((product) => {
const deficit = product.stockMin - product.stock
const orderValue = deficit * product.prixAchatHT
return [
product.reference,
`"${product.designation}"`,
product.categorie,
product.marque || '',
product.fournisseur?.nom || '',
product.stock.toString(),
product.stockMin.toString(),
deficit.toString(),
product.prixAchatHT.toFixed(2),
orderValue.toFixed(2),
product.emplacement || '',
product.codeBarre || ''
].join(',')
})
const csvContent = [headers.join(','), ...rows].join('\n')
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="stock_faible.csv"'
}
})
} catch (error) {
console.error('Error exporting low stock data:', error)
return NextResponse.json(
{ error: 'Failed to export low stock data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfYear, endOfYear, format } from 'date-fns'
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const range = searchParams.get('range') || 'month'
const now = new Date()
let startDate: Date
let endDate: Date
switch (range) {
case 'today':
startDate = startOfDay(now)
endDate = endOfDay(now)
break
case 'week':
startDate = startOfWeek(now, { weekStartsOn: 1 })
endDate = endOfWeek(now, { weekStartsOn: 1 })
break
case 'year':
startDate = startOfYear(now)
endDate = endOfYear(now)
break
case 'month':
default:
startDate = startOfMonth(now)
endDate = endOfMonth(now)
break
}
// Get sales with details
const sales = await db.vente.findMany({
where: {
date: { gte: startDate, lte: endDate },
statut: 'PAYEE'
},
include: {
client: {
select: {
nom: true,
prenom: true
}
},
employe: {
select: {
nom: true,
prenom: true
}
},
lignes: {
include: {
produit: {
select: {
reference: true,
designation: true,
categorie: true
}
}
}
},
paiements: true
},
orderBy: {
date: 'desc'
}
})
// Generate CSV content
const headers = [
'Numéro vente',
'Date',
'Client',
'Employé',
'Produits',
'Quantité totale',
'Montant HT',
'Montant TVA',
'Montant TTC',
'Remise',
'Modes de paiement',
'Statut atelier'
]
const rows = sales.map((sale) => {
const products = sale.lignes.map((l) =>
`${l.produit.designation} (${l.produit.categorie})`
).join('; ')
const quantities = sale.lignes.reduce((sum, l) => sum + l.quantite, 0)
const paymentMethods = sale.paiements.map((p) => p.mode).join(', ')
const atelierStatus = sale.statutAtelier
return [
sale.numero,
format(sale.date, 'dd/MM/yyyy HH:mm'),
sale.client ? `${sale.client.prenom} ${sale.client.nom}` : '',
sale.employe ? `${sale.employe.prenom} ${sale.employe.nom}` : '',
`"${products}"`,
quantities.toString(),
sale.montantHT.toFixed(2),
sale.montantTVA.toFixed(2),
sale.montantTTC.toFixed(2),
sale.remise.toFixed(2),
paymentMethods,
atelierStatus
].join(',')
})
const csvContent = [headers.join(','), ...rows].join('\n')
// Return as CSV file
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="ventes_${format(now, 'yyyy-MM-dd')}.csv"`
}
})
} catch (error) {
console.error('Error exporting sales data:', error)
return NextResponse.json(
{ error: 'Failed to export sales data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
// Stock valuation by category
const stockValuationRaw = await db.produit.groupBy({
by: ['categorie'],
where: {
actif: true
},
_sum: {
stock: true
},
_count: true
})
// Calculate value for each category
const stockValuationByCategory = await Promise.all(
stockValuationRaw.map(async (item) => {
const products = await db.produit.findMany({
where: {
categorie: item.categorie,
actif: true
},
select: {
stock: true,
prixAchatHT: true
}
})
const totalValue = products.reduce((sum, p) => sum + (p.stock * p.prixAchatHT), 0)
return {
category: item.categorie,
value: totalValue,
count: item._count
}
})
)
const totalValue = stockValuationByCategory.reduce((sum, item) => sum + item.value, 0)
// Low stock items
const lowStockItems = await db.produit.findMany({
where: {
actif: true,
stock: {
lt: db.produit.fields.stockMin
}
},
select: {
id: true,
reference: true,
designation: true,
categorie: true,
stock: true,
stockMin: true,
prixAchatHT: true
},
orderBy: {
stock: 'asc'
}
})
const lowStockItemsWithValue = lowStockItems.map((item) => ({
...item,
value: item.stock * item.prixAchatHT
}))
// Category breakdown
const categoryBreakdown = await Promise.all(
['MONTURE', 'VERRE', 'LENTILLE', 'ACCESSOIRE'].map(async (category) => {
const [totalProducts, activeProducts] = await Promise.all([
db.produit.count({ where: { categorie: category } }),
db.produit.count({ where: { categorie: category, actif: true } })
])
const products = await db.produit.findMany({
where: { categorie: category, actif: true },
select: { stock: true, prixAchatHT: true }
})
const totalStock = products.reduce((sum, p) => sum + p.stock, 0)
const stockValue = products.reduce((sum, p) => sum + (p.stock * p.prixAchatHT), 0)
return {
category,
totalProducts,
activeProducts,
totalStock,
stockValue
}
})
)
const inventoryData = {
stockValuation: {
totalValue,
byCategory: stockValuationByCategory
},
lowStockItems: lowStockItemsWithValue,
categoryBreakdown
}
return NextResponse.json(inventoryData)
} catch (error) {
console.error('Error fetching inventory data:', error)
return NextResponse.json(
{ error: 'Failed to fetch inventory data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,184 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfYear, endOfYear, format } from 'date-fns'
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const range = searchParams.get('range') || 'month'
const now = new Date()
let startDate: Date
let endDate: Date
let dateFormat: string
switch (range) {
case 'today':
startDate = startOfDay(now)
endDate = endOfDay(now)
dateFormat = 'HH:mm'
break
case 'week':
startDate = startOfWeek(now, { weekStartsOn: 1 })
endDate = endOfWeek(now, { weekStartsOn: 1 })
dateFormat = 'dd/MM'
break
case 'year':
startDate = startOfYear(now)
endDate = endOfYear(now)
dateFormat = 'MMM'
break
case 'month':
default:
startDate = startOfMonth(now)
endDate = endOfMonth(now)
dateFormat = 'dd/MM'
break
}
// Sales by date
const salesByDate = await db.vente.findMany({
where: {
date: { gte: startDate, lte: endDate },
statut: 'PAYEE'
},
select: {
date: true,
montantHT: true,
montantTTC: true
},
orderBy: {
date: 'asc'
}
})
// Group sales by date
const salesByDateGrouped = salesByDate.reduce((acc, sale) => {
const dateKey = format(sale.date, dateFormat)
const existing = acc.find((item) => item.date === dateKey)
if (existing) {
existing.sales += 1
existing.revenue += sale.montantTTC
} else {
acc.push({
date: dateKey,
sales: 1,
revenue: sale.montantTTC
})
}
return acc
}, [] as { date: string; sales: number; revenue: number }[])
// Sales by category (via products)
const salesByCategoryRaw = await db.ligneVente.groupBy({
by: ['produitId'],
where: {
vente: {
date: { gte: startDate, lte: endDate },
statut: 'PAYEE'
}
},
_sum: {
quantite: true,
montantTTC: true
}
})
// Get product categories and group
const salesByCategoryMap = new Map<string, { count: number; revenue: number }>()
for (const item of salesByCategoryRaw) {
const product = await db.produit.findUnique({
where: { id: item.produitId },
select: { categorie: true }
})
if (product) {
const existing = salesByCategoryMap.get(product.categorie) || { count: 0, revenue: 0 }
salesByCategoryMap.set(product.categorie, {
count: existing.count + (item._sum.quantite || 0),
revenue: existing.revenue + (item._sum.montantTTC || 0)
})
}
}
const salesByCategory = Array.from(salesByCategoryMap.entries()).map(([category, data]) => ({
category,
count: data.count,
revenue: data.revenue
}))
// Sales by employee
const salesByEmployeeRaw = await db.vente.groupBy({
by: ['employeId'],
where: {
date: { gte: startDate, lte: endDate },
statut: 'PAYEE',
employeId: { not: null }
},
_count: true,
_sum: {
montantTTC: true
}
})
const salesByEmployee = await Promise.all(
salesByEmployeeRaw.map(async (item) => {
const employee = await db.employe.findUnique({
where: { id: item.employeId! },
select: { nom: true, prenom: true }
})
return {
employee: employee ? `${employee.prenom} ${employee.nom}` : 'Inconnu',
sales: item._count,
revenue: item._sum.montantTTC || 0
}
})
)
// Sales by payment method
const salesByPaymentMethodRaw = await db.paiement.groupBy({
by: ['mode'],
where: {
date: { gte: startDate, lte: endDate },
vente: {
statut: 'PAYEE'
}
},
_count: true,
_sum: {
montant: true
}
})
const paymentMethodLabels: Record<string, string> = {
ESPECES: 'Espèces',
CARTE: 'Carte bancaire',
CHEQUE: 'Chèque',
VIREMENT: 'Virement',
BON_CAISSE: 'Bon de caisse'
}
const salesByPaymentMethod = salesByPaymentMethodRaw.map((item) => ({
method: paymentMethodLabels[item.mode] || item.mode,
count: item._count,
amount: item._sum.montant || 0
}))
const salesData = {
salesByDate: salesByDateGrouped,
salesByCategory,
salesByEmployee,
salesByPaymentMethod
}
return NextResponse.json(salesData)
} catch (error) {
console.error('Error fetching sales data:', error)
return NextResponse.json(
{ error: 'Failed to fetch sales data' },
{ status: 500 }
)
}
}

5
src/app/api/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}

122
src/app/globals.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

60
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,60 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
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({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "OptiqueStock - Gestion de Magasin d'Optique",
description: "Application de gestion de magasin d'optique : clients, produits, ventes, achats, atelier et rapports.",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "OptiqueStock",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#020617",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<ThemeProvider>
<CurrencyProvider>
<SessionProvider>
{children}
</SessionProvider>
</CurrencyProvider>
</ThemeProvider>
<Toaster />
</body>
</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>
)
}

423
src/app/page.tsx Normal file
View File

@@ -0,0 +1,423 @@
'use client'
import { useEffect, useState } from 'react'
import { signOut, useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
BarChart3,
BrainCircuit,
Database,
Eye,
LayoutDashboard,
Loader2,
LogOut,
Package,
PanelTop,
ShoppingCart,
Truck,
User,
UserCog,
Users,
Wrench,
} from 'lucide-react'
import POSModule from '@/components/pos/POSModule'
import { ProduitListe } from '@/components/products/ProduitListe'
import { ClientList } from '@/components/clients/client-list'
import AtelierModule from '@/components/atelier/AtelierModule'
import { SupplierList } from '@/components/suppliers/SupplierList'
import PurchaseModule from '@/components/purchases/PurchaseModule'
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 RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
type Module =
| 'HOME'
| 'CLIENTS'
| 'PRODUITS'
| 'FOURNISSEURS'
| 'ACHATS'
| 'VENTE'
| 'RAPPORTS'
| 'ATELIER'
| 'UTILISATEURS'
| 'IA'
| 'VENDEUR_WIZARD'
interface ModuleCard {
id: Module
title: string
description: string
icon: React.ReactNode
badge?: string
color: string
roles: RoleEmploye[]
}
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',
title: 'Gestion Clients',
description: 'Fiches clients, mesures de vision, ordonnances',
icon: <Users className="h-8 w-8" />,
color: 'bg-blue-500',
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
},
{
id: 'PRODUITS',
title: 'Gestion Produits',
description: 'Catalogue, stock, images, QR codes',
icon: <Package className="h-8 w-8" />,
badge: 'Alertes',
color: 'bg-emerald-500',
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
},
{
id: 'FOURNISSEURS',
title: 'Fournisseurs',
description: 'Gestion des fournisseurs et contacts',
icon: <Truck className="h-8 w-8" />,
color: 'bg-orange-500',
roles: ['RESPONSABLE', 'ADMIN'],
},
{
id: 'ACHATS',
title: 'Achats & Stock',
description: 'Reception, factures fournisseurs, entrees stock',
icon: <ShoppingCart className="h-8 w-8" />,
color: 'bg-purple-500',
roles: ['RESPONSABLE', 'ADMIN'],
},
{
id: 'VENTE',
title: 'Point de Vente',
description: 'Encaissement, facturation, POS',
icon: <ShoppingCart className="h-8 w-8" />,
badge: 'Actif',
color: 'bg-green-500',
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
},
{
id: 'ATELIER',
title: 'Atelier',
description: 'Montage de lunettes, commandes en cours',
icon: <Wrench className="h-8 w-8" />,
badge: 'En cours',
color: 'bg-amber-500',
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
},
{
id: 'RAPPORTS',
title: 'Rapports',
description: 'Statistiques, exports Excel/CSV/PDF',
icon: <BarChart3 className="h-8 w-8" />,
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() {
const { data: session, status } = useSession()
const router = useRouter()
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 = () => {
if (currentModule === 'HOME') {
return (
<div className="space-y-8">
<div className="space-y-2 text-center">
<h1 className="text-4xl font-bold text-foreground">OptiqueStock</h1>
<p className="text-lg text-muted-foreground">
Systeme de Gestion de Magasin d'Optique
</p>
</div>
{currentRole === 'ADMIN' && (
<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
key={module.id}
className="group cursor-pointer border-2 transition-all duration-200 hover:scale-105 hover:border-primary hover:shadow-lg"
onClick={() => setCurrentModule(module.id)}
>
<CardHeader>
<div className="mb-2 flex items-center justify-between">
<div className={`rounded-lg p-3 ${module.color} text-white`}>{module.icon}</div>
{module.badge && (
<Badge variant="secondary" className="text-xs">
{module.badge}
</Badge>
)}
</div>
<CardTitle className="text-lg">{module.title}</CardTitle>
<CardDescription className="text-sm">{module.description}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)
}
if (currentModule === 'CLIENTS') {
return (
<div className="space-y-6">
{moduleHeader}
<ClientList />
</div>
)
}
if (currentModule === 'VENTE') {
return (
<div className="space-y-6">
{moduleHeader}
<POSModule />
</div>
)
}
if (currentModule === 'PRODUITS') {
return (
<div className="space-y-6">
{moduleHeader}
<ProduitListe />
</div>
)
}
if (currentModule === 'ATELIER') {
return (
<div className="space-y-6">
{moduleHeader}
<AtelierModule />
</div>
)
}
if (currentModule === 'FOURNISSEURS') {
return (
<div className="space-y-6">
{moduleHeader}
<SupplierList />
</div>
)
}
if (currentModule === 'ACHATS') {
return (
<div className="space-y-6">
{moduleHeader}
<PurchaseModule />
</div>
)
}
if (currentModule === 'RAPPORTS') {
return (
<div className="space-y-6">
{moduleHeader}
<ReportsModule />
</div>
)
}
if (currentModule === 'UTILISATEURS') {
return (
<div className="space-y-6">
{moduleHeader}
<EmployeeManagement />
</div>
)
}
if (currentModule === 'IA') {
return (
<div className="space-y-6">
{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="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2">
<Eye className="h-6 w-6 text-primary-foreground" />
</div>
<div>
<h1 className="text-xl font-bold text-foreground">OptiqueStock</h1>
<p className="text-xs text-muted-foreground">Gestion de Magasin d'Optique</p>
</div>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<CurrencySelect />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User className="h-4 w-4" />
<span className="hidden sm:inline">{session?.user?.name}</span>
<Badge variant="secondary">{currentRole}</Badge>
</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>
</header>
<main className="container mx-auto px-4 py-8">{renderModule()}</main>
<footer className="mt-auto border-t bg-card">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>Version 1.0.0</span>
</div>
</div>
</div>
</footer>
</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

@@ -0,0 +1,930 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Wrench,
Clock,
Play,
CheckCircle2,
Bell,
User,
Package,
Eye,
Calendar,
FileText,
AlertCircle,
CheckCheck
} from 'lucide-react'
import { toast } from '@/hooks/use-toast'
// Types
interface Produit {
id: string
reference: string
designation: string
categorie: string
marque?: string
typeMonture?: string
typeVerre?: string
materiau?: string
couleur?: string
}
interface Client {
id: string
nom: string
prenom: string
email?: string
telephone: string
}
interface Patient {
id: string
odSphere?: number
odCylindre?: number
odAxe?: number
ogSphere?: number
ogCylindre?: number
ogAxe?: number
addition?: number
pd?: number
hauteur?: number
}
interface LigneVente {
id: string
produit: Produit
quantite: number
montantHT: number
montantTTC: number
}
interface WorkOrder {
id: string
numero: string
date: string
statutAtelier: 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'PRET' | 'RETIRE'
montantTTC: number
client?: Client
lignes: LigneVente[]
patients?: Patient[]
dateAtelier?: string
dateRetrait?: string
notes?: string
}
type StatusFilter = 'ALL' | 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'PRET' | 'RETIRE'
export default function AtelierModule() {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([])
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL')
const [loading, setLoading] = useState(false)
const [showDetailDialog, setShowDetailDialog] = useState(false)
const [showNotifyDialog, setShowNotifyDialog] = useState(false)
const [activeTab, setActiveTab] = useState<'orders' | 'ready' | 'history'>('orders')
// Load work orders
const loadWorkOrders = async () => {
setLoading(true)
try {
const response = await fetch('/api/atelier/orders?XTransformPort=3000')
if (response.ok) {
const data = await response.json()
setWorkOrders(data)
} else {
toast({
title: 'Erreur',
description: 'Impossible de charger les commandes atelier',
variant: 'destructive'
})
}
} catch (error) {
console.error('Error loading work orders:', error)
toast({
title: 'Erreur',
description: 'Impossible de charger les commandes atelier',
variant: 'destructive'
})
} finally {
setLoading(false)
}
}
// Update order status
const updateOrderStatus = async (orderId: string, newStatus: string) => {
console.log('Frontend: updateOrderStatus called')
console.log('Frontend: orderId =', orderId)
console.log('Frontend: newStatus =', newStatus)
try {
const response = await fetch(`/api/atelier/orders/${orderId}?XTransformPort=3000`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statutAtelier: newStatus })
})
console.log('Frontend: response.ok =', response.ok)
console.log('Frontend: response.status =', response.status)
if (response.ok) {
toast({
title: 'Statut mis à jour',
description: 'Le statut de la commande a été mis à jour'
})
loadWorkOrders()
if (selectedOrder?.id === orderId) {
setSelectedOrder({ ...selectedOrder, statutAtelier: newStatus as any })
}
} else {
const errorData = await response.json()
console.log('Frontend: errorData =', errorData)
toast({
title: 'Erreur',
description: errorData.error || 'Impossible de mettre à jour le statut',
variant: 'destructive'
})
}
} catch (error) {
console.error('Error updating order status:', error)
toast({
title: 'Erreur',
description: 'Impossible de mettre à jour le statut',
variant: 'destructive'
})
}
}
// View order details
const viewOrderDetails = async (order: WorkOrder) => {
setSelectedOrder(order)
setShowDetailDialog(true)
}
// Mark as ready for pickup
const markAsReady = async () => {
if (!selectedOrder) return
try {
const response = await fetch(`/api/atelier/orders/${selectedOrder.id}?XTransformPort=3000`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statutAtelier: 'PRET' })
})
if (response.ok) {
toast({
title: 'Commande prête',
description: 'La commande est marquée comme prête pour le retrait'
})
loadWorkOrders()
setShowNotifyDialog(false)
setShowDetailDialog(false)
} else {
toast({
title: 'Erreur',
description: 'Impossible de marquer la commande comme prête',
variant: 'destructive'
})
}
} catch (error) {
console.error('Error marking order as ready:', error)
toast({
title: 'Erreur',
description: 'Impossible de marquer la commande comme prête',
variant: 'destructive'
})
}
}
// Confirm order pickup (ready → retrieved)
const confirmRetrait = async (orderId: string) => {
if (!confirm('Confirmer que le client a récupéré ses lunettes ?')) {
return
}
try {
const response = await fetch(`/api/atelier/orders/${orderId}?XTransformPort=3000`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statutAtelier: 'RETIRE' })
})
if (response.ok) {
toast({
title: 'Retrait confirmé',
description: 'La commande a été marquée comme retirée'
})
loadWorkOrders()
} else {
toast({
title: 'Erreur',
description: 'Impossible de confirmer le retrait',
variant: 'destructive'
})
}
} catch (error) {
console.error('Error confirming pickup:', error)
toast({
title: 'Erreur',
description: 'Impossible de confirmer le retrait',
variant: 'destructive'
})
}
}
useEffect(() => {
loadWorkOrders()
}, [])
// Filter orders
const filteredOrders = workOrders.filter(order => {
if (statusFilter === 'ALL') return true
return order.statutAtelier === statusFilter
})
const readyOrders = workOrders.filter(order => order.statutAtelier === 'PRET')
// Get status badge
const getStatusBadge = (statut: string) => {
const variants: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
'EN_ATTENTE': {
color: 'bg-gray-500',
label: 'En attente',
icon: <Clock className="h-3 w-3 mr-1" />
},
'EN_COURS': {
color: 'bg-blue-500',
label: 'En cours',
icon: <Play className="h-3 w-3 mr-1" />
},
'TERMINE': {
color: 'bg-purple-500',
label: 'Terminé',
icon: <CheckCircle2 className="h-3 w-3 mr-1" />
},
'PRET': {
color: 'bg-green-500',
label: 'Prêt',
icon: <Bell className="h-3 w-3 mr-1" />
},
'RETIRE': {
color: 'bg-orange-500',
label: 'Retiré',
icon: <CheckCheck className="h-3 w-3 mr-1" />
}
}
const status = variants[statut] || variants['EN_ATTENTE']
return (
<Badge className={status.color}>
{status.icon}
{status.label}
</Badge>
)
}
// Format vision data
const formatVisionData = (patient?: Patient) => {
if (!patient) return null
return {
od: `Sph: ${patient.odSphere || '-'} | Cyl: ${patient.odCylindre || '-'} | Axe: ${patient.odAxe || '-'}`,
og: `Sph: ${patient.ogSphere || '-'} | Cyl: ${patient.ogCylindre || '-'} | Axe: ${patient.ogAxe || '-'}`,
addition: patient.addition ? `Add: ${patient.addition}` : null,
pd: patient.pd ? `PD: ${patient.pd}mm` : null,
hauteur: patient.hauteur ? `Haut: ${patient.hauteur}mm` : null
}
}
return (
<div className="space-y-6">
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">En attente</CardTitle>
<Clock className="h-4 w-4 text-gray-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{workOrders.filter(o => o.statutAtelier === 'EN_ATTENTE').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">En cours</CardTitle>
<Play className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{workOrders.filter(o => o.statutAtelier === 'EN_COURS').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Terminé</CardTitle>
<CheckCircle2 className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{workOrders.filter(o => o.statutAtelier === 'TERMINE').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Prêt à retirer</CardTitle>
<Bell className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{workOrders.filter(o => o.statutAtelier === 'PRET').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Retiré</CardTitle>
<CheckCheck className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length}
</div>
</CardContent>
</Card>
</div>
{/* Main Content */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'orders' | 'ready')}>
<TabsList className="grid w-full max-w-lg grid-cols-3">
<TabsTrigger value="orders">Commandes</TabsTrigger>
<TabsTrigger value="ready">
Prêtes
{readyOrders.length > 0 && (
<Badge className="ml-2 bg-green-500">{readyOrders.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="history">
Historique
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length > 0 && (
<Badge className="ml-2 bg-orange-500">{workOrders.filter(o => o.statutAtelier === 'RETIRE').length}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="orders" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Wrench className="h-5 w-5" />
Commandes de Montage
</CardTitle>
<CardDescription>
Gérez les commandes de montage de lunettes
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant={statusFilter === 'ALL' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('ALL')}
>
Toutes
</Button>
<Button
variant={statusFilter === 'EN_ATTENTE' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('EN_ATTENTE')}
>
En attente
</Button>
<Button
variant={statusFilter === 'EN_COURS' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('EN_COURS')}
>
En cours
</Button>
<Button
variant={statusFilter === 'TERMINE' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('TERMINE')}
>
Terminé
</Button>
<Button
variant={statusFilter === 'RETIRE' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('RETIRE')}
>
Retiré
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-center py-8 text-gray-500">
Chargement...
</div>
) : filteredOrders.length === 0 ? (
<div className="text-center py-8 space-y-4">
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">
{statusFilter === 'ALL'
? 'Aucune commande de montage en cours'
: statusFilter === 'RETIRE'
? 'Aucune commande retirée'
: `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"`
}
</p>
</div>
) : (
<ScrollArea className="max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>N° Commande</TableHead>
<TableHead>Date</TableHead>
<TableHead>Client</TableHead>
<TableHead>Produits</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium">{order.numero}</TableCell>
<TableCell>
{new Date(order.date).toLocaleDateString('fr-FR')}
</TableCell>
<TableCell>
{order.client
? `${order.client.prenom} ${order.client.nom}`
: 'Client anonyme'
}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{order.lignes.slice(0, 2).map((ligne) => (
<span key={ligne.id} className="text-xs">
{ligne.produit.designation}
</span>
))}
{order.lignes.length > 2 && (
<span className="text-xs text-gray-500">
+{order.lignes.length - 2} autre(s)
</span>
)}
</div>
</TableCell>
<TableCell>{getStatusBadge(order.statutAtelier)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => viewOrderDetails(order)}
>
Voir détails
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ready">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-green-500" />
Commandes prêtes pour retrait
</CardTitle>
<CardDescription>
Les commandes terminées et prêtes à être remises aux clients
</CardDescription>
</CardHeader>
<CardContent>
{readyOrders.length === 0 ? (
<div className="text-center py-8">
<CheckCircle2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">
Aucune commande prête pour le retrait
</p>
</div>
) : (
<ScrollArea className="max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>N° Commande</TableHead>
<TableHead>Date</TableHead>
<TableHead>Client</TableHead>
<TableHead>Téléphone</TableHead>
<TableHead>Prêt depuis</TableHead>
<TableHead className="text-right">Montant TTC</TableHead>
<TableHead className="text-center">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{readyOrders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium">{order.numero}</TableCell>
<TableCell>
{new Date(order.date).toLocaleDateString('fr-FR')}
</TableCell>
<TableCell>
{order.client
? `${order.client.prenom} ${order.client.nom}`
: 'Client anonyme'
}
</TableCell>
<TableCell>
{order.client?.telephone || '-'}
</TableCell>
<TableCell>
{order.dateAtelier
? new Date(order.dateAtelier).toLocaleDateString('fr-FR')
: new Date(order.date).toLocaleDateString('fr-FR')
}
</TableCell>
<TableCell className="text-right font-bold">
{order.montantTTC.toFixed(2)}
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => confirmRetrait(order.id)}
className="text-green-600 hover:text-green-700 hover:bg-green-50"
>
<CheckCheck className="h-4 w-4 mr-2" />
Confirmer retrait
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCheck className="h-5 w-5 text-orange-500" />
Historique des retraits
</CardTitle>
<CardDescription>
Les commandes qui ont é retirées par les clients
</CardDescription>
</CardHeader>
<CardContent>
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length === 0 ? (
<div className="text-center py-8">
<CheckCheck className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">
Aucune commande retirée
</p>
</div>
) : (
<ScrollArea className="max-h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>N° Commande</TableHead>
<TableHead>Date de vente</TableHead>
<TableHead>Client</TableHead>
<TableHead>Téléphone</TableHead>
<TableHead>Prêt le</TableHead>
<TableHead>Retiré le</TableHead>
<TableHead className="text-right">Montant TTC</TableHead>
<TableHead className="text-center">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workOrders.filter(o => o.statutAtelier === 'RETIRE').map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium">{order.numero}</TableCell>
<TableCell>
{new Date(order.date).toLocaleDateString('fr-FR')}
</TableCell>
<TableCell>
{order.client
? `${order.client.prenom} ${order.client.nom}`
: 'Client anonyme'
}
</TableCell>
<TableCell>
{order.client?.telephone || '-'}
</TableCell>
<TableCell>
{order.dateAtelier
? new Date(order.dateAtelier).toLocaleDateString('fr-FR')
: '-'
}
</TableCell>
<TableCell className="text-green-600 font-medium">
{order.dateRetrait
? new Date(order.dateRetrait).toLocaleDateString('fr-FR')
: '-'
}
</TableCell>
<TableCell className="text-right font-bold">
{order.montantTTC.toFixed(2)}
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => viewOrderDetails(order)}
>
Voir détails
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Order Detail Dialog */}
<Dialog open={showDetailDialog} onOpenChange={setShowDetailDialog}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Détails de la commande {selectedOrder?.numero}
</DialogTitle>
<DialogDescription>
Gérez le montage et le statut de cette commande
</DialogDescription>
</DialogHeader>
{selectedOrder && (
<div className="space-y-6">
{/* Status and Actions */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Statut actuel</p>
{getStatusBadge(selectedOrder.statutAtelier)}
</div>
<div className="flex gap-2">
{selectedOrder.statutAtelier === 'EN_ATTENTE' && (
<Button
onClick={() => updateOrderStatus(selectedOrder.id, 'EN_COURS')}
className="bg-blue-500 hover:bg-blue-600"
>
<Play className="h-4 w-4 mr-2" />
Commencer le montage
</Button>
)}
{selectedOrder.statutAtelier === 'EN_COURS' && (
<Button
onClick={() => updateOrderStatus(selectedOrder.id, 'TERMINE')}
className="bg-purple-500 hover:bg-purple-600"
>
<CheckCircle2 className="h-4 w-4 mr-2" />
Marquer comme terminé
</Button>
)}
{selectedOrder.statutAtelier === 'TERMINE' && (
<Button
onClick={() => {
setSelectedOrder(selectedOrder)
setShowNotifyDialog(true)
}}
className="bg-green-500 hover:bg-green-600"
>
<Bell className="h-4 w-4 mr-2" />
Marquer comme prêt
</Button>
)}
</div>
</div>
<Separator />
{/* Client Information */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-500" />
<h3 className="font-semibold">Client</h3>
</div>
{selectedOrder.client ? (
<div className="bg-gray-50 p-4 rounded-lg">
<p className="font-medium">{selectedOrder.client.prenom} {selectedOrder.client.nom}</p>
<p className="text-sm text-gray-600">{selectedOrder.client.telephone}</p>
{selectedOrder.client.email && (
<p className="text-sm text-gray-600">{selectedOrder.client.email}</p>
)}
</div>
) : (
<p className="text-sm text-gray-500">Client anonyme</p>
)}
</div>
{/* Vision Measurements */}
{selectedOrder.patients && selectedOrder.patients.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-gray-500" />
<h3 className="font-semibold">Mesures de vision</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedOrder.patients.map((patient) => {
const vision = formatVisionData(patient)
if (!vision) return null
return (
<Card key={patient.id}>
<CardContent className="pt-4 space-y-2">
<div className="text-sm">
<p className="font-medium text-blue-600">Œil Droit (OD)</p>
<p className="text-xs text-gray-600">{vision.od}</p>
</div>
<div className="text-sm">
<p className="font-medium text-green-600">Œil Gauche (OG)</p>
<p className="text-xs text-gray-600">{vision.og}</p>
</div>
{(vision.addition || vision.pd || vision.hauteur) && (
<div className="text-xs text-gray-600">
{[vision.addition, vision.pd, vision.hauteur].filter(Boolean).join(' | ')}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
)}
{/* Products */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-gray-500" />
<h3 className="font-semibold">Produits à monter</h3>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Référence</TableHead>
<TableHead>Désignation</TableHead>
<TableHead>Catégorie</TableHead>
<TableHead className="text-right">Quantité</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedOrder.lignes.map((ligne) => (
<TableRow key={ligne.id}>
<TableCell className="font-medium">{ligne.produit.reference}</TableCell>
<TableCell>{ligne.produit.designation}</TableCell>
<TableCell>
<Badge variant="outline">{ligne.produit.categorie}</Badge>
</TableCell>
<TableCell className="text-right">{ligne.quantite}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Dates */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-500" />
<h3 className="font-semibold">Dates</h3>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Date de vente:</p>
<p className="font-medium">
{new Date(selectedOrder.date).toLocaleDateString('fr-FR')} à{' '}
{new Date(selectedOrder.date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div>
<p className="text-gray-500">Dernière mise à jour atelier:</p>
<p className="font-medium">
{selectedOrder.dateAtelier
? `${new Date(selectedOrder.dateAtelier).toLocaleDateString('fr-FR')} à ${new Date(selectedOrder.dateAtelier).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`
: '-'
}
</p>
</div>
</div>
{selectedOrder.statutAtelier === 'RETIRE' && selectedOrder.dateRetrait && (
<div className="mt-2 pt-2 border-t">
<div className="text-sm">
<p className="text-gray-500">Date de retrait:</p>
<p className="font-medium text-orange-600">
{new Date(selectedOrder.dateRetrait).toLocaleDateString('fr-FR')} à{' '}
{new Date(selectedOrder.dateRetrait).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
)}
</div>
{selectedOrder.notes && (
<>
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-gray-500" />
<h3 className="font-semibold">Notes</h3>
</div>
<p className="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">
{selectedOrder.notes}
</p>
</div>
</>
)}
<Separator />
<div className="flex justify-between items-center">
<div className="text-lg font-bold">
Total TTC: {selectedOrder.montantTTC.toFixed(2)}
</div>
<Button variant="outline" onClick={() => setShowDetailDialog(false)}>
Fermer
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Notify Client Dialog */}
<Dialog open={showNotifyDialog} onOpenChange={setShowNotifyDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<Bell className="h-5 w-5" />
Notifier le client
</DialogTitle>
<DialogDescription>
Confirmer que cette commande est prête pour le retrait
</DialogDescription>
</DialogHeader>
{selectedOrder && (
<div className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="font-medium mb-2">La commande {selectedOrder.numero} est prête!</p>
<p className="text-sm text-gray-600">
{selectedOrder.client
? `Le client ${selectedOrder.client.prenom} ${selectedOrder.client.nom} pourra être notifié que ses lunettes sont prêtes.`
: 'La commande est marquée comme prête pour retrait.'
}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setShowNotifyDialog(false)}>
Annuler
</Button>
<Button onClick={markAsReady} className="bg-green-500 hover:bg-green-600">
<CheckCircle2 className="h-4 w-4 mr-2" />
Confirmer et marquer comme prêt
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

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,396 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
Phone,
Mail,
MapPin,
Calendar,
FileText,
Plus,
Eye,
Pencil,
Trash2,
Activity,
} from 'lucide-react'
import { VisionForm } from './vision-form'
import { OrdonnanceList } from './ordonnance-list'
interface Client {
id: string
nom: string
prenom: string
email: string | null
telephone: string
adresse: string | null
ville: string | null
codePostal: string | null
dateNaissance: string | null
notes: string | null
createdAt: string
updatedAt: string
}
interface Patient {
id: string
clientId: string
dateCreation: string
odSphere: number | null
odCylindre: number | null
odAxe: number | null
ogSphere: number | null
ogCylindre: number | null
ogAxe: number | null
addition: number | null
pd: number | null
hauteur: number | null
notes: string | null
createdAt: string
updatedAt: string
ordonnances?: Ordonnance[]
}
interface Ordonnance {
id: string
patientId: string
numero: string
dateEmission: string
medecin: string | null
notes: string | null
createdAt: string
updatedAt: string
}
interface ClientDetailProps {
client: Client
onClose: () => void
onUpdate: () => void
}
export function ClientDetail({ client, onClose, onUpdate }: ClientDetailProps) {
const [patients, setPatients] = useState<Patient[]>([])
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null)
const [isVisionFormOpen, setIsVisionFormOpen] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
console.log('ClientDetail mounted/updated with client:', client.id, client.nom, client.prenom)
// Reset selected patient when client changes
setSelectedPatient(null)
fetchPatients()
}, [client.id])
const fetchPatients = async () => {
try {
setLoading(true)
console.log('Fetching patients for client:', client.id, client.nom, client.prenom)
const response = await fetch(`/api/clients/${client.id}/patients`)
if (response.ok) {
const data = await response.json()
console.log('Patients received for', client.nom, client.prenom, ':', data)
setPatients(data)
}
} catch (error) {
console.error('Error fetching patients:', error)
} finally {
setLoading(false)
}
}
const handlePatientSaved = async () => {
setIsVisionFormOpen(false)
setSelectedPatient(null)
await fetchPatients()
onUpdate()
}
const formatDate = (dateString: string | null) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleDateString('fr-FR')
}
const getAge = (dateNaissance: string | null) => {
if (!dateNaissance) return null
const today = new Date()
const birth = new Date(dateNaissance)
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--
}
return age
}
const formatPrescription = (sphere: number | null, cylindre: number | null, axe: number | null) => {
if (sphere === null) return '-'
const sph = sphere.toFixed(2)
const cyl = cylindre !== null ? ` ${cylindre >= 0 ? '+' : ''}${cylindre.toFixed(2)}` : ''
const ax = axe !== null ? ` ax${axe}` : ''
return `${sph}${cyl}${ax}`
}
const handleDeletePatient = async (patientId: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cette fiche de vision ?')) {
return
}
try {
const response = await fetch(`/api/patients/${patientId}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchPatients()
onUpdate()
} else {
alert('Erreur lors de la suppression de la fiche de vision')
}
} catch (error) {
console.error('Error deleting patient:', error)
alert('Erreur lors de la suppression de la fiche de vision')
}
}
return (
<div className="space-y-6">
{/* Client Information Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Informations du Client
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Nom complet</p>
<p className="text-lg font-semibold">
{client.prenom} {client.nom}
{getAge(client.dateNaissance) !== null && (
<Badge variant="secondary" className="ml-2">
{getAge(client.dateNaissance)} ans
</Badge>
)}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Date de naissance</p>
<p className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
{formatDate(client.dateNaissance)}
</p>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">Téléphone</p>
<p className="flex items-center gap-2">
<Phone className="h-4 w-4 text-gray-400" />
{client.telephone}
</p>
</div>
{client.email && (
<div>
<p className="text-sm font-medium text-gray-500">Email</p>
<p className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
{client.email}
</p>
</div>
)}
</div>
{(client.adresse || client.ville || client.codePostal) && (
<>
<Separator />
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Adresse</p>
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-gray-400 mt-0.5" />
<div>
{client.adresse && <div className="text-sm">{client.adresse}</div>}
<div className="text-sm">
{client.codePostal && `${client.codePostal} `}
{client.ville}
</div>
</div>
</div>
</div>
</>
)}
{client.notes && (
<>
<Separator />
<div>
<p className="text-sm font-medium text-gray-500 mb-2">Notes</p>
<p className="text-sm bg-gray-50 p-3 rounded-lg">{client.notes}</p>
</div>
</>
)}
<Separator />
<div className="flex justify-between items-center text-sm text-gray-500">
<span>Créé le {formatDate(client.createdAt)}</span>
<span>Modifié le {formatDate(client.updatedAt)}</span>
</div>
</CardContent>
</Card>
{/* Vision Measurements */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Fiches de Vision
<Badge variant="secondary">{patients.length}</Badge>
</CardTitle>
<Button
size="sm"
onClick={() => {
setSelectedPatient(null)
setIsVisionFormOpen(true)
}}
>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Fiche
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-center text-gray-500 py-8">Chargement...</p>
) : patients.length === 0 ? (
<div className="text-center py-8">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-2" />
<p className="text-gray-500">Aucune fiche de vision enregistrée</p>
</div>
) : (
<ScrollArea className="h-[400px]">
<div className="space-y-3">
{patients.map((patient) => (
<Card key={patient.id} className="p-4">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-gray-500">
Créée le {formatDate(patient.dateCreation)}
</p>
{patient.notes && (
<p className="text-sm text-gray-600 mt-1">{patient.notes}</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedPatient(patient)
setIsVisionFormOpen(true)
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeletePatient(patient.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Œil Droit (OD)</p>
<div className="space-y-1 text-sm">
<p>Sphère: {patient.odSphere !== null ? patient.odSphere.toFixed(2) : '-'}</p>
<p>Cylindre: {patient.odCylindre !== null ? patient.odCylindre.toFixed(2) : '-'}</p>
<p>Axe: {patient.odAxe !== null ? `${patient.odAxe}°` : '-'}</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Œil Gauche (OG)</p>
<div className="space-y-1 text-sm">
<p>Sphère: {patient.ogSphere !== null ? patient.ogSphere.toFixed(2) : '-'}</p>
<p>Cylindre: {patient.ogCylindre !== null ? patient.ogCylindre.toFixed(2) : '-'}</p>
<p>Axe: {patient.ogAxe !== null ? `${patient.ogAxe}°` : '-'}</p>
</div>
</div>
</div>
{(patient.addition !== null || patient.pd !== null || patient.hauteur !== null) && (
<>
<Separator />
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Addition</p>
<p>{patient.addition !== null ? patient.addition.toFixed(2) : '-'}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">DP (mm)</p>
<p>{patient.pd !== null ? patient.pd.toFixed(1) : '-'}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Hauteur (mm)</p>
<p>{patient.hauteur !== null ? patient.hauteur.toFixed(1) : '-'}</p>
</div>
</div>
</>
)}
{patient.ordonnances && patient.ordonnances.length > 0 && (
<>
<Separator />
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Ordonnances</p>
<div className="flex flex-wrap gap-2">
{patient.ordonnances.map((ord) => (
<Badge key={ord.id} variant="outline" className="gap-1">
<FileText className="h-3 w-3" />
{ord.numero}
</Badge>
))}
</div>
</div>
</>
)}
</div>
</Card>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Vision Form Dialog */}
{isVisionFormOpen && (
<VisionForm
patient={selectedPatient}
clientId={client.id}
onSave={handlePatientSaved}
onCancel={() => {
setIsVisionFormOpen(false)
setSelectedPatient(null)
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,281 @@
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2 } from 'lucide-react'
const clientSchema = z.object({
nom: z.string().min(1, 'Le nom est requis'),
prenom: z.string().min(1, 'Le prénom est requis'),
email: z.string().email('Email invalide').optional().or(z.literal('')),
telephone: z.string().min(10, 'Le téléphone doit contenir au moins 10 chiffres'),
adresse: z.string().optional(),
ville: z.string().optional(),
codePostal: z.string().optional(),
dateNaissance: z.string().optional(),
notes: z.string().optional(),
})
type ClientFormValues = z.infer<typeof clientSchema>
interface Client {
id: string
nom: string
prenom: string
email: string | null
telephone: string
adresse: string | null
ville: string | null
codePostal: string | null
dateNaissance: string | null
notes: string | null
}
interface ClientFormProps {
client?: Client | null
onSave: () => void
onCancel: () => void
}
export function ClientForm({ client, onSave, onCancel }: ClientFormProps) {
const [loading, setLoading] = useState(false)
const form = useForm<ClientFormValues>({
resolver: zodResolver(clientSchema),
defaultValues: {
nom: client?.nom || '',
prenom: client?.prenom || '',
email: client?.email || '',
telephone: client?.telephone || '',
adresse: client?.adresse || '',
ville: client?.ville || '',
codePostal: client?.codePostal || '',
dateNaissance: client?.dateNaissance ? client.dateNaissance.split('T')[0] : '',
notes: client?.notes || '',
},
})
const onSubmit = async (data: ClientFormValues) => {
setLoading(true)
try {
const url = client ? `/api/clients/${client.id}` : '/api/clients'
const method = client ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
email: data.email || null,
adresse: data.adresse || null,
ville: data.ville || null,
codePostal: data.codePostal || null,
dateNaissance: data.dateNaissance || null,
notes: data.notes || null,
}),
})
if (response.ok) {
onSave()
} else {
const error = await response.json()
console.error('Error saving client:', error)
alert('Erreur lors de la sauvegarde du client')
}
} catch (error) {
console.error('Error saving client:', error)
alert('Erreur lors de la sauvegarde du client')
} finally {
setLoading(false)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="prenom"
render={({ field }) => (
<FormItem>
<FormLabel>Prénom *</FormLabel>
<FormControl>
<Input placeholder="Jean" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nom"
render={({ field }) => (
<FormItem>
<FormLabel>Nom *</FormLabel>
<FormControl>
<Input placeholder="Dupont" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Informations de contact</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="telephone"
render={({ field }) => (
<FormItem>
<FormLabel>Téléphone *</FormLabel>
<FormControl>
<Input placeholder="0612345678" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="jean.dupont@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Adresse</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adresse"
render={({ field }) => (
<FormItem>
<FormLabel>Adresse</FormLabel>
<FormControl>
<Input placeholder="123 Rue de la République" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="codePostal"
render={({ field }) => (
<FormItem>
<FormLabel>Code Postal</FormLabel>
<FormControl>
<Input placeholder="75001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ville"
render={({ field }) => (
<FormItem>
<FormLabel>Ville</FormLabel>
<FormControl>
<Input placeholder="Paris" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Informations complémentaires</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="dateNaissance"
render={({ field }) => (
<FormItem>
<FormLabel>Date de naissance</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Informations supplémentaires sur le client..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{client ? 'Mettre à jour' : 'Créer le client'}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,365 @@
'use client'
import { useState, useEffect } from 'react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Search,
Plus,
Eye,
Pencil,
Phone,
Mail,
MapPin,
Calendar,
FileText,
User
} from 'lucide-react'
import { ClientForm } from './client-form'
import { ClientDetail } from './client-detail'
interface Client {
id: string
nom: string
prenom: string
email: string | null
telephone: string
adresse: string | null
ville: string | null
codePostal: string | null
dateNaissance: string | null
notes: string | null
createdAt: string
patients?: Patient[]
}
interface Patient {
id: string
dateCreation: string
odSphere: number | null
odCylindre: number | null
odAxe: number | null
ogSphere: number | null
ogCylindre: number | null
ogAxe: number | null
addition: number | null
pd: number | null
hauteur: number | null
notes: string | null
ordonnances?: Ordonnance[]
}
interface Ordonnance {
id: string
numero: string
dateEmission: string
medecin: string | null
notes: string | null
}
export function ClientList() {
const [clients, setClients] = useState<Client[]>([])
const [filteredClients, setFilteredClients] = useState<Client[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [loading, setLoading] = useState(true)
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
const [editingClient, setEditingClient] = useState<Client | null>(null)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
useEffect(() => {
fetchClients()
}, [])
useEffect(() => {
if (searchTerm === '') {
setFilteredClients(clients)
} else {
const term = searchTerm.toLowerCase()
const filtered = clients.filter(client =>
client.nom.toLowerCase().includes(term) ||
client.prenom.toLowerCase().includes(term) ||
client.telephone.includes(term) ||
(client.email && client.email.toLowerCase().includes(term)) ||
(client.ville && client.ville.toLowerCase().includes(term))
)
setFilteredClients(filtered)
}
}, [searchTerm, clients])
const fetchClients = async () => {
try {
const response = await fetch('/api/clients')
if (response.ok) {
const data = await response.json()
setClients(data)
setFilteredClients(data)
}
} catch (error) {
console.error('Error fetching clients:', error)
} finally {
setLoading(false)
}
}
const handleClientSaved = async () => {
setIsAddDialogOpen(false)
setEditingClient(null)
await fetchClients()
}
const handleViewClient = (client: Client) => {
console.log('handleViewClient called with:', client.id, client.nom, client.prenom, client)
// Force dialog to close first if already open with different client
if (isDetailDialogOpen && selectedClient?.id !== client.id) {
setIsDetailDialogOpen(false)
// Small delay to ensure dialog closes
setTimeout(() => {
setSelectedClient(client)
setIsDetailDialogOpen(true)
}, 50)
} else {
setSelectedClient(client)
setIsDetailDialogOpen(true)
}
}
const handleEditClient = (client: Client) => {
setEditingClient(client)
setIsAddDialogOpen(true)
}
const formatDate = (dateString: string | null) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleDateString('fr-FR')
}
const getAge = (dateNaissance: string | null) => {
if (!dateNaissance) return null
const today = new Date()
const birth = new Date(dateNaissance)
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--
}
return age
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">Gestion des Clients</h2>
<p className="text-sm text-gray-500">
{clients.length} client{clients.length !== 1 ? 's' : ''} enregistré{clients.length !== 1 ? 's' : ''}
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Nouveau Client
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingClient ? 'Modifier le Client' : 'Nouveau Client'}
</DialogTitle>
<DialogDescription>
{editingClient
? 'Modifiez les informations du client'
: 'Remplissez les informations pour créer un nouveau client'}
</DialogDescription>
</DialogHeader>
<ClientForm
client={editingClient}
onSave={handleClientSaved}
onCancel={() => {
setIsAddDialogOpen(false)
setEditingClient(null)
}}
/>
</DialogContent>
</Dialog>
</div>
{/* Search */}
<Card>
<CardContent className="pt-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Rechercher par nom, prénom, téléphone, email ou ville..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Clients Table */}
<Card>
<CardContent className="p-0">
<ScrollArea className="h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Adresse</TableHead>
<TableHead>Naissance</TableHead>
<TableHead>Fiches</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
Chargement...
</TableCell>
</TableRow>
) : filteredClients.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
<div className="flex flex-col items-center gap-2">
<User className="h-12 w-12 text-gray-400" />
<p className="text-gray-500">
{searchTerm ? 'Aucun client trouvé' : 'Aucun client enregistré'}
</p>
</div>
</TableCell>
</TableRow>
) : (
filteredClients.map((client) => (
<TableRow key={client.id} className="hover:bg-gray-50">
<TableCell>
<div>
<div className="font-medium">
{client.prenom} {client.nom}
</div>
<div className="text-sm text-gray-500">
{getAge(client.dateNaissance) !== null && (
<span className="mr-2">{getAge(client.dateNaissance)} ans</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
{client.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="h-3 w-3 text-gray-400" />
<span className="truncate max-w-[150px]">{client.email}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<Phone className="h-3 w-3 text-gray-400" />
<span>{client.telephone}</span>
</div>
</div>
</TableCell>
<TableCell>
{client.adresse || client.ville || client.codePostal ? (
<div className="space-y-1">
{client.adresse && (
<div className="text-sm text-gray-600 truncate max-w-[150px]">
{client.adresse}
</div>
)}
<div className="flex items-center gap-1 text-sm text-gray-500">
<MapPin className="h-3 w-3" />
<span>
{client.codePostal && `${client.codePostal} `}
{client.ville}
</span>
</div>
</div>
) : (
<span className="text-gray-400 text-sm">Non renseignée</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-3 w-3 text-gray-400" />
{formatDate(client.dateNaissance)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="gap-1">
<FileText className="h-3 w-3" />
{client.patients?.length || 0}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewClient(client)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditClient(client)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
{/* Client Detail Dialog */}
<Dialog open={isDetailDialogOpen} onOpenChange={setIsDetailDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Détail du Client</DialogTitle>
<DialogDescription>
Informations complètes et fiches de vision
</DialogDescription>
</DialogHeader>
{selectedClient && (
<ClientDetail
key={selectedClient.id}
client={selectedClient}
onClose={() => setIsDetailDialogOpen(false)}
onUpdate={fetchClients}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,378 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Plus,
FileText,
Pencil,
Trash2,
Upload,
Download,
Eye,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
const ordonnanceSchema = z.object({
numero: z.string().min(1, 'Le numéro est requis'),
dateEmission: z.string().min(1, 'La date est requise'),
medecin: z.string().optional(),
notes: z.string().optional(),
})
type OrdonnanceFormValues = z.infer<typeof ordonnanceSchema>
interface Ordonnance {
id: string
patientId: string
numero: string
dateEmission: string
medecin: string | null
notes: string | null
createdAt: string
updatedAt: string
fichiers?: Fichier[]
}
interface Fichier {
id: string
nom: string
type: string
url: string
taille: number
mimeType: string
createdAt: string
}
interface OrdonnanceListProps {
patientId: string
onUpdate?: () => void
}
export function OrdonnanceList({ patientId, onUpdate }: OrdonnanceListProps) {
const [ordonnances, setOrdonnances] = useState<Ordonnance[]>([])
const [editingOrdonnance, setEditingOrdonnance] = useState<Ordonnance | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchOrdonnances()
}, [patientId])
const fetchOrdonnances = async () => {
try {
setLoading(true)
const response = await fetch(`/api/patients/${patientId}/ordonnances`)
if (response.ok) {
const data = await response.json()
setOrdonnances(data)
}
} catch (error) {
console.error('Error fetching ordonnances:', error)
} finally {
setLoading(false)
}
}
const form = useForm<OrdonnanceFormValues>({
resolver: zodResolver(ordonnanceSchema),
defaultValues: {
numero: '',
dateEmission: new Date().toISOString().split('T')[0],
medecin: '',
notes: '',
},
})
const onSubmit = async (data: OrdonnanceFormValues) => {
try {
const url = editingOrdonnance
? `/api/ordonnances/${editingOrdonnance.id}`
: '/api/ordonnances'
const method = editingOrdonnance ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
patientId,
medecin: data.medecin || null,
notes: data.notes || null,
}),
})
if (response.ok) {
setIsDialogOpen(false)
setEditingOrdonnance(null)
form.reset()
await fetchOrdonnances()
onUpdate?.()
} else {
alert('Erreur lors de la sauvegarde de l\'ordonnance')
}
} catch (error) {
console.error('Error saving ordonnance:', error)
alert('Erreur lors de la sauvegarde de l\'ordonnance')
}
}
const handleDelete = async (ordonnanceId: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cette ordonnance ?')) {
return
}
try {
const response = await fetch(`/api/ordonnances/${ordonnanceId}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchOrdonnances()
onUpdate?.()
} else {
alert('Erreur lors de la suppression de l\'ordonnance')
}
} catch (error) {
console.error('Error deleting ordonnance:', error)
alert('Erreur lors de la suppression de l\'ordonnance')
}
}
const handleEdit = (ordonnance: Ordonnance) => {
setEditingOrdonnance(ordonnance)
form.reset({
numero: ordonnance.numero,
dateEmission: ordonnance.dateEmission.split('T')[0],
medecin: ordonnance.medecin || '',
notes: ordonnance.notes || '',
})
setIsDialogOpen(true)
}
const handleNew = () => {
setEditingOrdonnance(null)
form.reset({
numero: '',
dateEmission: new Date().toISOString().split('T')[0],
medecin: '',
notes: '',
})
setIsDialogOpen(true)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fr-FR')
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
Ordonnances
<Badge variant="secondary">{ordonnances.length}</Badge>
</h3>
<Button size="sm" onClick={handleNew}>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Ordonnance
</Button>
</div>
{loading ? (
<p className="text-center text-gray-500 py-4">Chargement...</p>
) : ordonnances.length === 0 ? (
<Card>
<CardContent className="py-8 text-center">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-2" />
<p className="text-gray-500">Aucune ordonnance enregistrée</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{ordonnances.map((ordonnance) => (
<Card key={ordonnance.id}>
<CardContent className="pt-6">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline">{ordonnance.numero}</Badge>
<span className="text-sm text-gray-500">
{formatDate(ordonnance.dateEmission)}
</span>
</div>
{ordonnance.medecin && (
<p className="text-sm text-gray-600">
Médecin: {ordonnance.medecin}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(ordonnance)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(ordonnance.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{ordonnance.notes && (
<p className="text-sm text-gray-600 bg-gray-50 p-2 rounded">
{ordonnance.notes}
</p>
)}
{ordonnance.fichiers && ordonnance.fichiers.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-2">Documents joints</p>
<div className="flex flex-wrap gap-2">
{ordonnance.fichiers.map((fichier) => (
<Badge key={fichier.id} variant="secondary" className="gap-1">
<FileText className="h-3 w-3" />
{fichier.nom}
<span className="text-xs text-gray-500">
({formatFileSize(fichier.taille)})
</span>
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editingOrdonnance ? 'Modifier l\'Ordonnance' : 'Nouvelle Ordonnance'}
</DialogTitle>
<DialogDescription>
Enregistrez les informations de l'ordonnance médicale
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="numero"
render={({ field }) => (
<FormItem>
<FormLabel>Numéro *</FormLabel>
<FormControl>
<Input placeholder="ORD-2024-001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dateEmission"
render={({ field }) => (
<FormItem>
<FormLabel>Date d'émission *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="medecin"
render={({ field }) => (
<FormItem>
<FormLabel>Médecin</FormLabel>
<FormControl>
<Input placeholder="Dr. Martin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Notes supplémentaires..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Annuler
</Button>
<Button type="submit">
{editingOrdonnance ? 'Mettre à jour' : 'Créer'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,369 @@
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Loader2, Eye, Activity } from 'lucide-react'
const visionSchema = z.object({
odSphere: z.string().optional(),
odCylindre: z.string().optional(),
odAxe: z.string().optional(),
ogSphere: z.string().optional(),
ogCylindre: z.string().optional(),
ogAxe: z.string().optional(),
addition: z.string().optional(),
pd: z.string().optional(),
hauteur: z.string().optional(),
notes: z.string().optional(),
})
type VisionFormValues = z.infer<typeof visionSchema>
interface Patient {
id: string
clientId: string
dateCreation: string
odSphere: number | null
odCylindre: number | null
odAxe: number | null
ogSphere: number | null
ogCylindre: number | null
ogAxe: number | null
addition: number | null
pd: number | null
hauteur: number | null
notes: string | null
}
interface VisionFormProps {
patient?: Patient | null
clientId: string
onSave: () => void
onCancel: () => void
}
export function VisionForm({ patient, clientId, onSave, onCancel }: VisionFormProps) {
const [loading, setLoading] = useState(false)
const form = useForm<VisionFormValues>({
resolver: zodResolver(visionSchema),
defaultValues: {
odSphere: '',
odCylindre: '',
odAxe: '',
ogSphere: '',
ogCylindre: '',
ogAxe: '',
addition: '',
pd: '',
hauteur: '',
notes: '',
},
})
// Reset form when patient changes
useEffect(() => {
if (patient) {
form.reset({
odSphere: patient.odSphere?.toString() || '',
odCylindre: patient.odCylindre?.toString() || '',
odAxe: patient.odAxe?.toString() || '',
ogSphere: patient.ogSphere?.toString() || '',
ogCylindre: patient.ogCylindre?.toString() || '',
ogAxe: patient.ogAxe?.toString() || '',
addition: patient.addition?.toString() || '',
pd: patient.pd?.toString() || '',
hauteur: patient.hauteur?.toString() || '',
notes: patient.notes || '',
})
} else {
form.reset({
odSphere: '',
odCylindre: '',
odAxe: '',
ogSphere: '',
ogCylindre: '',
ogAxe: '',
addition: '',
pd: '',
hauteur: '',
notes: '',
})
}
}, [patient, form])
const onSubmit = async (data: VisionFormValues) => {
setLoading(true)
try {
const url = patient ? `/api/patients/${patient.id}` : '/api/patients'
const method = patient ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientId: patient?.clientId || clientId,
odSphere: data.odSphere ? parseFloat(data.odSphere) : null,
odCylindre: data.odCylindre ? parseFloat(data.odCylindre) : null,
odAxe: data.odAxe ? parseInt(data.odAxe) : null,
ogSphere: data.ogSphere ? parseFloat(data.ogSphere) : null,
ogCylindre: data.ogCylindre ? parseFloat(data.ogCylindre) : null,
ogAxe: data.ogAxe ? parseInt(data.ogAxe) : null,
addition: data.addition ? parseFloat(data.addition) : null,
pd: data.pd ? parseFloat(data.pd) : null,
hauteur: data.hauteur ? parseFloat(data.hauteur) : null,
notes: data.notes || null,
}),
})
if (response.ok) {
onSave()
} else {
const error = await response.json()
console.error('Error saving patient:', error)
alert('Erreur lors de la sauvegarde de la fiche de vision')
}
} catch (error) {
console.error('Error saving patient:', error)
alert('Erreur lors de la sauvegarde de la fiche de vision')
} finally {
setLoading(false)
}
}
return (
<Dialog open={true} onOpenChange={onCancel}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
{patient ? 'Modifier la Fiche de Vision' : 'Nouvelle Fiche de Vision'}
</DialogTitle>
<DialogDescription>
Enregistrez les mesures de vision et les paramètres de prescription
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Right Eye (OD) */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Eye className="h-4 w-4" />
Œil Droit (OD)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="odSphere"
render={({ field }) => (
<FormItem>
<FormLabel>Sphère</FormLabel>
<FormControl>
<Input type="number" step="0.25" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="odCylindre"
render={({ field }) => (
<FormItem>
<FormLabel>Cylindre</FormLabel>
<FormControl>
<Input type="number" step="0.25" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="odAxe"
render={({ field }) => (
<FormItem>
<FormLabel>Axe (°)</FormLabel>
<FormControl>
<Input type="number" step="1" min="0" max="180" placeholder="0-180" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* Left Eye (OG) */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Eye className="h-4 w-4" />
Œil Gauche (OG)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="ogSphere"
render={({ field }) => (
<FormItem>
<FormLabel>Sphère</FormLabel>
<FormControl>
<Input type="number" step="0.25" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ogCylindre"
render={({ field }) => (
<FormItem>
<FormLabel>Cylindre</FormLabel>
<FormControl>
<Input type="number" step="0.25" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ogAxe"
render={({ field }) => (
<FormItem>
<FormLabel>Axe (°)</FormLabel>
<FormControl>
<Input type="number" step="1" min="0" max="180" placeholder="0-180" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* Additional Parameters */}
<Card>
<CardHeader>
<CardTitle className="text-base">Paramètres Additionnels</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="addition"
render={({ field }) => (
<FormItem>
<FormLabel>Addition</FormLabel>
<FormControl>
<Input type="number" step="0.25" placeholder="0.00" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pd"
render={({ field }) => (
<FormItem>
<FormLabel>DP (mm)</FormLabel>
<FormControl>
<Input type="number" step="0.5" placeholder="Distance pupillaire" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hauteur"
render={({ field }) => (
<FormItem>
<FormLabel>Hauteur (mm)</FormLabel>
<FormControl>
<Input type="number" step="0.5" placeholder="Hauteur" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* Notes */}
<Card>
<CardHeader>
<CardTitle className="text-base">Notes</CardTitle>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder="Notes supplémentaires sur la prescription..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Separator />
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{patient ? 'Mettre à jour' : 'Enregistrer la fiche'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

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

Some files were not shown because too many files have changed in this diff Show More