Initial commit

This commit is contained in:
2026-05-30 14:33:11 +01:00
commit a8c372177f
156 changed files with 38163 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-*

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# 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/

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

141
README.md Normal file
View File

@@ -0,0 +1,141 @@
# 🚀 Welcome to Z.ai Code Scaffold
A modern, production-ready web application scaffold powered by cutting-edge technologies, designed to accelerate your development with [Z.ai](https://chat.z.ai)'s AI-powered coding assistance.
## ✨ Technology Stack
This scaffold provides a robust foundation built with:
### 🎯 Core Framework
- **⚡ Next.js 16** - The React framework for production with App Router
- **📘 TypeScript 5** - Type-safe JavaScript for better developer experience
- **🎨 Tailwind CSS 4** - Utility-first CSS framework for rapid UI development
### 🧩 UI Components & Styling
- **🧩 shadcn/ui** - High-quality, accessible components built on Radix UI
- **🎯 Lucide React** - Beautiful & consistent icon library
- **🌈 Framer Motion** - Production-ready motion library for React
- **🎨 Next Themes** - Perfect dark mode in 2 lines of code
### 📋 Forms & Validation
- **🎣 React Hook Form** - Performant forms with easy validation
- **✅ Zod** - TypeScript-first schema validation
### 🔄 State Management & Data Fetching
- **🐻 Zustand** - Simple, scalable state management
- **🔄 TanStack Query** - Powerful data synchronization for React
- **🌐 Fetch** - Promise-based HTTP request
### 🗄️ Database & Backend
- **🗄️ Prisma** - Next-generation TypeScript ORM
- **🔐 NextAuth.js** - Complete open-source authentication solution
### 🎨 Advanced UI Features
- **📊 TanStack Table** - Headless UI for building tables and datagrids
- **🖱️ DND Kit** - Modern drag and drop toolkit for React
- **📊 Recharts** - Redefined chart library built with React and D3
- **🖼️ Sharp** - High performance image processing
### 🌍 Internationalization & Utilities
- **🌍 Next Intl** - Internationalization library for Next.js
- **📅 Date-fns** - Modern JavaScript date utility library
- **🪝 ReactUse** - Collection of essential React hooks for modern development
## 🎯 Why This Scaffold?
- **🏎️ Fast Development** - Pre-configured tooling and best practices
- **🎨 Beautiful UI** - Complete shadcn/ui component library with advanced interactions
- **🔒 Type Safety** - Full TypeScript configuration with Zod validation
- **📱 Responsive** - Mobile-first design principles with smooth animations
- **🗄️ Database Ready** - Prisma ORM configured for rapid backend development
- **🔐 Auth Included** - NextAuth.js for secure authentication flows
- **📊 Data Visualization** - Charts, tables, and drag-and-drop functionality
- **🌍 i18n Ready** - Multi-language support with Next Intl
- **🚀 Production Ready** - Optimized build and deployment settings
- **🤖 AI-Friendly** - Structured codebase perfect for AI assistance
## 🚀 Quick Start
```bash
# Install dependencies
bun install
# Start development server
bun run dev
# Build for production
bun run build
# Start production server
bun start
```
Open [http://localhost:3000](http://localhost:3000) to see your application running.
## 🤖 Powered by Z.ai
This scaffold is optimized for use with [Z.ai](https://chat.z.ai) - your AI assistant for:
- **💻 Code Generation** - Generate components, pages, and features instantly
- **🎨 UI Development** - Create beautiful interfaces with AI assistance
- **🔧 Bug Fixing** - Identify and resolve issues with intelligent suggestions
- **📝 Documentation** - Auto-generate comprehensive documentation
- **🚀 Optimization** - Performance improvements and best practices
Ready to build something amazing? Start chatting with Z.ai at [chat.z.ai](https://chat.z.ai) and experience the future of AI-powered development!
## 📁 Project Structure
```
src/
├── app/ # Next.js App Router pages
├── components/ # Reusable React components
│ └── ui/ # shadcn/ui components
├── hooks/ # Custom React hooks
└── lib/ # Utility functions and configurations
```
## 🎨 Available Features & Components
This scaffold includes a comprehensive set of modern web development tools:
### 🧩 UI Components (shadcn/ui)
- **Layout**: Card, Separator, Aspect Ratio, Resizable Panels
- **Forms**: Input, Textarea, Select, Checkbox, Radio Group, Switch
- **Feedback**: Alert, Toast (Sonner), Progress, Skeleton
- **Navigation**: Breadcrumb, Menubar, Navigation Menu, Pagination
- **Overlay**: Dialog, Sheet, Popover, Tooltip, Hover Card
- **Data Display**: Badge, Avatar, Calendar
### 📊 Advanced Data Features
- **Tables**: Powerful data tables with sorting, filtering, pagination (TanStack Table)
- **Charts**: Beautiful visualizations with Recharts
- **Forms**: Type-safe forms with React Hook Form + Zod validation
### 🎨 Interactive Features
- **Animations**: Smooth micro-interactions with Framer Motion
- **Drag & Drop**: Modern drag-and-drop functionality with DND Kit
- **Theme Switching**: Built-in dark/light mode support
### 🔐 Backend Integration
- **Authentication**: Ready-to-use auth flows with NextAuth.js
- **Database**: Type-safe database operations with Prisma
- **API Client**: HTTP requests with Fetch + TanStack Query
- **State Management**: Simple and scalable with Zustand
### 🌍 Production Features
- **Internationalization**: Multi-language support with Next Intl
- **Image Optimization**: Automatic image processing with Sharp
- **Type Safety**: End-to-end TypeScript with Zod validation
- **Essential Hooks**: 100+ useful React hooks with ReactUse for common patterns
## 🤝 Get Started with Z.ai
1. **Clone this scaffold** to jumpstart your project
2. **Visit [chat.z.ai](https://chat.z.ai)** to access your AI coding assistant
3. **Start building** with intelligent code generation and assistance
4. **Deploy with confidence** using the production-ready setup
---
Built with ❤️ for the developer community. Supercharged by [Z.ai](https://chat.z.ai) 🚀

View File

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

2019
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

12
next.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
/* 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

96
package.json Normal file
View File

@@ -0,0 +1,96 @@
{
"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"
},
"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",
"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/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;

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

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,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,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,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,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,192 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutVente, ModePaiement } from '@prisma/client'
// 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 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 }
)
}
}
// 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: null // Can be updated with authentication
}
})
}
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,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;
}
}

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

@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Z.ai Code Scaffold - AI-Powered Development",
description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.",
keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"],
authors: [{ name: "Z.ai Team" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
openGraph: {
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
url: "https://chat.z.ai",
siteName: "Z.ai",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
{children}
<Toaster />
</body>
</html>
);
}

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

@@ -0,0 +1,378 @@
'use client'
import { useState } 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 {
Users,
Package,
Truck,
ShoppingCart,
FileText,
BarChart3,
Eye,
LayoutDashboard,
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'
type Module = 'HOME' | 'CLIENTS' | 'PRODUITS' | 'FOURNISSEURS' | 'ACHATS' | 'VENTE' | 'RAPPORTS' | 'ATELIER'
interface ModuleCard {
id: Module
title: string
description: string
icon: React.ReactNode
badge?: string
color: string
}
const modules: ModuleCard[] = [
{
id: 'CLIENTS',
title: 'Gestion Clients',
description: 'Fiches clients, mesures de vision, ordonnances',
icon: <Users className="h-8 w-8" />,
color: 'bg-blue-500'
},
{
id: 'PRODUITS',
title: 'Gestion Produits',
description: 'Catalogue, stock, images, QR codes',
icon: <Package className="h-8 w-8" />,
badge: 'Alertes',
color: 'bg-emerald-500'
},
{
id: 'FOURNISSEURS',
title: 'Fournisseurs',
description: 'Gestion des fournisseurs et contacts',
icon: <Truck className="h-8 w-8" />,
color: 'bg-orange-500'
},
{
id: 'ACHATS',
title: 'Achats & Stock',
description: 'Réception, factures fournisseurs, entrées stock',
icon: <ShoppingCart className="h-8 w-8" />,
color: 'bg-purple-500'
},
{
id: 'VENTE',
title: 'Point de Vente',
description: 'Encaissement, facturation, POS',
icon: <ShoppingCart className="h-8 w-8" />,
badge: 'Actif',
color: 'bg-green-500'
},
{
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'
},
{
id: 'RAPPORTS',
title: 'Rapports',
description: 'Statistiques, exports Excel/CSV/PDF',
icon: <BarChart3 className="h-8 w-8" />,
color: 'bg-cyan-500'
}
]
export default function Home() {
const [currentModule, setCurrentModule] = useState<Module>('HOME')
const renderModule = () => {
if (currentModule === 'HOME') {
return (
<div className="space-y-8">
<div className="text-center space-y-2">
<h1 className="text-4xl font-bold text-gray-900">OptiqueStock</h1>
<p className="text-lg text-gray-600">Système de Gestion de Magasin d'Optique</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{modules.map((module) => (
<Card
key={module.id}
className="group cursor-pointer transition-all duration-200 hover:shadow-lg hover:scale-105 border-2 hover:border-primary"
onClick={() => setCurrentModule(module.id)}
>
<CardHeader>
<div className={`flex items-center justify-between mb-2`}>
<div className={`p-3 rounded-lg ${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>
)
}
const moduleInfo = modules.find(m => m.id === currentModule)
// Render Client Management module if selected
if (currentModule === 'CLIENTS') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<ClientList />
</div>
)
}
// Render POS module if selected
if (currentModule === 'VENTE') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<POSModule />
</div>
)
}
// Render Products module if selected
if (currentModule === 'PRODUITS') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<ProduitListe />
</div>
)
}
// Render Atelier module if selected
if (currentModule === 'ATELIER') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<AtelierModule />
</div>
)
}
// Render Suppliers module if selected
if (currentModule === 'FOURNISSEURS') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<SupplierList />
</div>
)
}
// Render Purchases module if selected
if (currentModule === 'ACHATS') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<PurchaseModule />
</div>
)
}
// Render Reports module if selected
if (currentModule === 'RAPPORTS') {
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<ReportsModule />
</div>
)
}
return (
<div className="space-y-6">
<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 à l'accueil
</Button>
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
{moduleInfo?.icon}
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
</div>
</div>
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className={`p-6 rounded-full ${moduleInfo?.color} bg-opacity-10 mb-4`}>
<Eye className="h-16 w-16 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-700 mb-2">
Module en développement
</h3>
<p className="text-gray-500 text-center max-w-md">
Le module <strong>{moduleInfo?.title}</strong> est actuellement en cours de développement.
Veuillez revenir ultérieurement.
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<header className="bg-white border-b 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="p-2 bg-primary rounded-lg">
<Eye className="h-6 w-6 text-primary-foreground" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">OptiqueStock</h1>
<p className="text-xs text-gray-500">Gestion de Magasin d'Optique</p>
</div>
</div>
<div className="flex items-center gap-4">
<Badge variant="outline" className="text-sm">
v1.0.0
</Badge>
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
OP
</div>
</div>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
{renderModule()}
</main>
<footer className="bg-white border-t mt-auto">
<div className="container mx-auto px-4 py-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-gray-500">
© 2024 OptiqueStock. Tous droits réservés.
</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>Support: support@optiquestock.com</span>
<span></span>
<span>Version 1.0.0</span>
</div>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,965 @@
'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'
})
}
}
// Seed sample data
const seedSampleData = async () => {
try {
const response = await fetch('/api/atelier/seed?XTransformPort=3000', {
method: 'POST'
})
if (response.ok) {
toast({
title: 'Données ajoutées',
description: 'Les données de test ont été créées avec succès'
})
loadWorkOrders()
} else {
const error = await response.json()
toast({
title: 'Erreur',
description: error.error || 'Impossible de créer les données de test',
variant: 'destructive'
})
}
} catch (error) {
console.error('Error seeding data:', error)
toast({
title: 'Erreur',
description: 'Impossible de créer les données de test',
variant: 'destructive'
})
}
}
useEffect(() => {
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>
{statusFilter === 'ALL' && workOrders.length === 0 && (
<Button onClick={seedSampleData} variant="outline">
<Wrench className="h-4 w-4 mr-2" />
Ajouter des données de test
</Button>
)}
</div>
) : (
<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,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,996 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
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 { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
ShoppingCart,
Search,
Plus,
Minus,
Trash2,
User,
CreditCard,
DollarSign,
FileText,
X,
CheckCircle,
AlertCircle
} from 'lucide-react'
import { toast } from '@/hooks/use-toast'
// Types
interface Produit {
id: string
reference: string
designation: string
categorie: string
prixVenteTTC: number
tva: number
stock: number
marque?: string
typeMonture?: string
}
interface Client {
id: string
nom: string
prenom: string
email?: string
telephone: string
}
interface CartItem {
produit: Produit
quantite: number
prixUnitaireHT: number
prixUnitaireTTC: number
remise: number
montantHT: number
montantTTC: number
}
interface Paiement {
mode: 'ESPECES' | 'CARTE' | 'CHEQUE' | 'VIREMENT' | 'BON_CAISSE'
montant: number
reference?: string
}
interface Vente {
id: string
numero: string
date: string
statut: 'INITIEE' | 'PAYEE' | 'ANNULEE' | 'REMBOURSEE'
montantHT: number
montantTVA: number
montantTTC: number
remise: number
client?: Client
}
// Helper functions
const calculateHTFromTTC = (ttc: number, tvaRate: number) => {
return ttc / (1 + tvaRate / 100)
}
const calculateTTCFromHT = (ht: number, tvaRate: number) => {
return ht * (1 + tvaRate / 100)
}
export default function POSModule() {
// State
const [products, setProducts] = useState<Produit[]>([])
const [filteredProducts, setFilteredProducts] = useState<Produit[]>([])
const [clients, setClients] = useState<Client[]>([])
const [cart, setCart] = useState<CartItem[]>([])
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [globalDiscount, setGlobalDiscount] = useState(0)
const [payments, setPayments] = useState<Paiement[]>([])
const [currentPayment, setCurrentPayment] = useState<Paiement>({
mode: 'ESPECES',
montant: 0
})
const [showCustomerDialog, setShowCustomerDialog] = useState(false)
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
const [showInvoiceDialog, setShowInvoiceDialog] = useState(false)
const [currentSale, setCurrentSale] = useState<Vente | null>(null)
const [newClient, setNewClient] = useState({
nom: '',
prenom: '',
email: '',
telephone: ''
})
const [activeTab, setActiveTab] = useState<'pos' | 'history'>('pos')
const [salesHistory, setSalesHistory] = useState<Vente[]>([])
// Calculations
const cartTotals = cart.reduce((acc, item) => ({
ht: acc.ht + item.montantHT,
ttc: acc.ttc + item.montantTTC
}), { ht: 0, ttc: 0 })
const totalTVA = cartTotals.ttc - cartTotals.ht
const globalDiscountAmount = (cartTotals.ttc * globalDiscount) / 100
const finalTTC = cartTotals.ttc - globalDiscountAmount
const totalPaid = payments.reduce((sum, p) => sum + p.montant, 0)
const remainingToPay = finalTTC - totalPaid
// Load functions
const loadProducts = async () => {
try {
const response = await fetch('/api/pos/products')
if (response.ok) {
const data = await response.json()
setProducts(data)
setFilteredProducts(data)
}
} catch (error) {
console.error('Error loading products:', error)
toast({
title: 'Erreur',
description: 'Impossible de charger les produits',
variant: 'destructive'
})
}
}
const loadClients = async () => {
try {
const response = await fetch('/api/pos/clients')
if (response.ok) {
const data = await response.json()
setClients(data)
}
} catch (error) {
console.error('Error loading clients:', error)
}
}
const loadSalesHistory = async () => {
try {
const response = await fetch('/api/pos/sales')
if (response.ok) {
const data = await response.json()
setSalesHistory(data)
}
} catch (error) {
console.error('Error loading sales history:', error)
}
}
// Seed sample data
const seedSampleData = async () => {
try {
const response = await fetch('/api/pos/seed', {
method: 'POST'
})
if (response.ok) {
toast({
title: 'Données ajoutées',
description: 'Les données de test ont été créées avec succès'
})
loadProducts()
loadClients()
}
} catch (error) {
console.error('Error seeding data:', error)
toast({
title: 'Erreur',
description: 'Impossible de créer les données de test',
variant: 'destructive'
})
}
}
// Load products on mount
useEffect(() => {
loadProducts()
loadClients()
loadSalesHistory()
}, [])
// Search products
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredProducts(products)
return
}
const query = searchQuery.toLowerCase()
const filtered = products.filter(p =>
p.reference.toLowerCase().includes(query) ||
p.designation.toLowerCase().includes(query) ||
p.marque?.toLowerCase().includes(query) ||
p.categorie.toLowerCase().includes(query)
)
setFilteredProducts(filtered)
}, [searchQuery, products])
// Add to cart
const addToCart = (product: Produit) => {
if (product.stock <= 0) {
toast({
title: 'Stock épuisé',
description: 'Ce produit n\'est plus en stock',
variant: 'destructive'
})
return
}
setCart(prevCart => {
const existingItem = prevCart.find(item => item.produit.id === product.id)
const maxQty = existingItem ? existingItem.quantite + 1 : 1
if (maxQty > product.stock) {
toast({
title: 'Stock insuffisant',
description: `Seulement ${product.stock} unités disponibles`,
variant: 'destructive'
})
return prevCart
}
if (existingItem) {
const updatedItem = {
...existingItem,
quantite: maxQty,
montantHT: existingItem.prixUnitaireHT * maxQty,
montantTTC: existingItem.prixUnitaireTTC * maxQty
}
return prevCart.map(item =>
item.produit.id === product.id ? updatedItem : item
)
}
const prixHT = calculateHTFromTTC(product.prixVenteTTC, product.tva)
const newItem: CartItem = {
produit: product,
quantite: 1,
prixUnitaireHT: prixHT,
prixUnitaireTTC: product.prixVenteTTC,
remise: 0,
montantHT: prixHT,
montantTTC: product.prixVenteTTC
}
return [...prevCart, newItem]
})
}
// Update cart item quantity
const updateQuantity = (productId: string, newQuantity: number) => {
if (newQuantity < 1) return
const item = cart.find(i => i.produit.id === productId)
if (!item) return
if (newQuantity > item.produit.stock) {
toast({
title: 'Stock insuffisant',
description: `Seulement ${item.produit.stock} unités disponibles`,
variant: 'destructive'
})
return
}
setCart(prevCart =>
prevCart.map(item => {
if (item.produit.id === productId) {
return {
...item,
quantite: newQuantity,
montantHT: item.prixUnitaireHT * newQuantity,
montantTTC: item.prixUnitaireTTC * newQuantity
}
}
return item
})
)
}
// Remove from cart
const removeFromCart = (productId: string) => {
setCart(prevCart => prevCart.filter(item => item.produit.id !== productId))
}
// Clear cart
const clearCart = () => {
setCart([])
setSelectedClient(null)
setGlobalDiscount(0)
setPayments([])
setCurrentPayment({ mode: 'ESPECES', montant: 0 })
}
// Create new client
const handleCreateClient = async () => {
if (!newClient.nom || !newClient.prenom || !newClient.telephone) {
toast({
title: 'Champs requis',
description: 'Veuillez remplir tous les champs obligatoires',
variant: 'destructive'
})
return
}
try {
const response = await fetch('/api/pos/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newClient)
})
if (response.ok) {
const createdClient = await response.json()
setSelectedClient(createdClient)
setClients([...clients, createdClient])
setNewClient({ nom: '', prenom: '', email: '', telephone: '' })
setShowCustomerDialog(false)
toast({
title: 'Client créé',
description: 'Le client a été créé avec succès'
})
}
} catch (error) {
console.error('Error creating client:', error)
toast({
title: 'Erreur',
description: 'Impossible de créer le client',
variant: 'destructive'
})
}
}
// Add payment
const addPayment = () => {
if (currentPayment.montant <= 0) {
toast({
title: 'Montant invalide',
description: 'Veuillez entrer un montant valide',
variant: 'destructive'
})
return
}
if (totalPaid + currentPayment.montant > finalTTC) {
toast({
title: 'Montant excessif',
description: 'Le paiement dépasse le montant dû',
variant: 'destructive'
})
return
}
setPayments([...payments, currentPayment])
setCurrentPayment({ mode: 'ESPECES', montant: 0 })
}
// Remove payment
const removePayment = (index: number) => {
setPayments(payments.filter((_, i) => i !== index))
}
// Complete sale
const completeSale = async () => {
if (cart.length === 0) {
toast({
title: 'Panier vide',
description: 'Veuillez ajouter des produits au panier',
variant: 'destructive'
})
return
}
if (remainingToPay > 0.01) {
toast({
title: 'Paiement incomplet',
description: `Il reste ${remainingToPay.toFixed(2)} € à payer`,
variant: 'destructive'
})
return
}
try {
const response = await fetch('/api/pos/sales', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: selectedClient?.id || null,
lignes: cart.map(item => ({
produitId: item.produit.id,
quantite: item.quantite,
prixUnitaireHT: item.prixUnitaireHT,
prixUnitaireTTC: item.prixUnitaireTTC,
remise: item.remise,
montantHT: item.montantHT,
montantTTC: item.montantTTC
})),
paiements: payments,
remise: globalDiscount,
montantHT: cartTotals.ht - (globalDiscountAmount / (1 + 0.2)),
montantTVA: totalTVA,
montantTTC: finalTTC,
notes: ''
})
})
if (response.ok) {
const sale = await response.json()
setCurrentSale(sale)
setShowInvoiceDialog(true)
loadSalesHistory()
loadProducts()
clearCart()
toast({
title: 'Vente enregistrée',
description: `Vente ${sale.numero} complétée avec succès`
})
}
} catch (error) {
console.error('Error completing sale:', error)
toast({
title: 'Erreur',
description: 'Impossible d\'enregistrer la vente',
variant: 'destructive'
})
}
}
// Get status badge
const getStatusBadge = (statut: string) => {
const variants: Record<string, { color: string; label: string }> = {
'INITIEE': { color: 'bg-yellow-500', label: 'Initiée' },
'PAYEE': { color: 'bg-green-500', label: 'Payée' },
'ANNULEE': { color: 'bg-red-500', label: 'Annulée' },
'REMBOURSEE': { color: 'bg-blue-500', label: 'Remboursée' }
}
const status = variants[statut] || variants['INITIEE']
return <Badge className={status.color}>{status.label}</Badge>
}
return (
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'pos' | 'history')}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="pos">Point de Vente</TabsTrigger>
<TabsTrigger value="history">Historique</TabsTrigger>
</TabsList>
<TabsContent value="pos" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Products */}
<div className="lg:col-span-1 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Recherche Produits
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder="Référence, désignation, marque..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-2">
{filteredProducts.map(product => (
<Card
key={product.id}
className={`cursor-pointer transition-all hover:shadow-md hover:border-primary ${
product.stock <= 0 ? 'opacity-50' : ''
}`}
onClick={() => product.stock > 0 && addToCart(product)}
>
<CardContent className="p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<p className="text-sm font-medium">{product.designation}</p>
<p className="text-xs text-gray-500">{product.reference}</p>
{product.marque && (
<p className="text-xs text-gray-400">{product.marque}</p>
)}
<Badge variant="outline" className="mt-2 text-xs">
{product.categorie}
</Badge>
</div>
<div className="text-right">
<p className="font-bold text-primary">
{product.prixVenteTTC.toFixed(2)}
</p>
<p className="text-xs text-gray-500">
Stock: {product.stock}
</p>
</div>
</div>
</CardContent>
</Card>
))}
{filteredProducts.length === 0 && (
<div className="text-center py-8 space-y-4">
<p className="text-gray-500">
{products.length === 0
? 'Aucun produit dans la base de données'
: 'Aucun produit trouvé'}
</p>
{products.length === 0 && (
<Button onClick={seedSampleData} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Ajouter des données de test
</Button>
)}
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
{/* Middle & Right columns: Cart and Checkout */}
<div className="lg:col-span-2 space-y-4">
{/* Customer Selection */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Client
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4 items-center">
<Select
value={selectedClient?.id || ''}
onValueChange={(value) => {
const client = clients.find(c => c.id === value)
setSelectedClient(client || null)
}}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Sélectionner un client" />
</SelectTrigger>
<SelectContent>
{clients.map(client => (
<SelectItem key={client.id} value={client.id}>
{client.prenom} {client.nom} - {client.telephone}
</SelectItem>
))}
</SelectContent>
</Select>
<Dialog open={showCustomerDialog} onOpenChange={setShowCustomerDialog}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="h-4 w-4 mr-2" />
Nouveau Client
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Créer un nouveau client</DialogTitle>
<DialogDescription>
Remplissez les informations du client
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Nom *</Label>
<Input
value={newClient.nom}
onChange={(e) => setNewClient({ ...newClient, nom: e.target.value })}
/>
</div>
<div>
<Label>Prénom *</Label>
<Input
value={newClient.prenom}
onChange={(e) => setNewClient({ ...newClient, prenom: e.target.value })}
/>
</div>
</div>
<div>
<Label>Téléphone *</Label>
<Input
value={newClient.telephone}
onChange={(e) => setNewClient({ ...newClient, telephone: e.target.value })}
/>
</div>
<div>
<Label>Email</Label>
<Input
type="email"
value={newClient.email}
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
/>
</div>
<Button onClick={handleCreateClient} className="w-full">
Créer le client
</Button>
</div>
</DialogContent>
</Dialog>
{selectedClient && (
<Button variant="ghost" onClick={() => setSelectedClient(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{selectedClient && (
<div className="mt-2 text-sm text-gray-600">
<span className="font-medium">{selectedClient.prenom} {selectedClient.nom}</span>
{selectedClient.email && <span> - {selectedClient.email}</span>}
</div>
)}
</CardContent>
</Card>
{/* Shopping Cart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
Panier ({cart.length} articles)
</div>
{cart.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearCart}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
{cart.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Le panier est vide
</div>
) : (
<>
<div className="max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Produit</TableHead>
<TableHead className="text-center">Qté</TableHead>
<TableHead className="text-right">Prix Unit.</TableHead>
<TableHead className="text-right">Total TTC</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cart.map(item => (
<TableRow key={item.produit.id}>
<TableCell>
<div>
<p className="font-medium text-sm">{item.produit.designation}</p>
<p className="text-xs text-gray-500">{item.produit.reference}</p>
</div>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={() => updateQuantity(item.produit.id, item.quantite - 1)}
>
<Minus className="h-3 w-3" />
</Button>
<span className="w-8 text-center text-sm">{item.quantite}</span>
<Button
variant="outline"
size="icon"
className="h-6 w-6"
onClick={() => updateQuantity(item.produit.id, item.quantite + 1)}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell className="text-right text-sm">
{item.prixUnitaireTTC.toFixed(2)}
</TableCell>
<TableCell className="text-right font-medium">
{item.montantTTC.toFixed(2)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeFromCart(item.produit.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Separator className="my-4" />
{/* Totals */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Total HT:</span>
<span>{cartTotals.ht.toFixed(2)} </span>
</div>
<div className="flex justify-between text-sm">
<span>TVA:</span>
<span>{totalTVA.toFixed(2)} </span>
</div>
<div className="flex justify-between items-center gap-2">
<span className="text-sm">Remise globale:</span>
<div className="flex items-center gap-2">
<Input
type="number"
min="0"
max="100"
value={globalDiscount}
onChange={(e) => setGlobalDiscount(Number(e.target.value))}
className="w-20 h-8 text-right"
/>
<span className="text-sm">%</span>
</div>
<span className="text-sm font-medium text-red-500">
-{globalDiscountAmount.toFixed(2)}
</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total TTC:</span>
<span className="text-primary">{finalTTC.toFixed(2)} </span>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Payment */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Paiement
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Mode de paiement</Label>
<Select
value={currentPayment.mode}
onValueChange={(value: any) =>
setCurrentPayment({ ...currentPayment, mode: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ESPECES">Espèces</SelectItem>
<SelectItem value="CARTE">Carte bancaire</SelectItem>
<SelectItem value="CHEQUE">Chèque</SelectItem>
<SelectItem value="VIREMENT">Virement</SelectItem>
<SelectItem value="BON_CAISSE">Bon de caisse</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Montant</Label>
<div className="flex gap-2">
<Input
type="number"
min="0"
step="0.01"
value={currentPayment.montant || ''}
onChange={(e) => setCurrentPayment({
...currentPayment,
montant: Number(e.target.value)
})}
placeholder="0.00"
/>
<Button onClick={addPayment} disabled={cart.length === 0}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Payments list */}
{payments.length > 0 && (
<div className="space-y-2">
<Label>Paiements enregistrés</Label>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{payments.map((payment, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<div className="flex items-center gap-2">
{payment.mode === 'ESPECES' && <DollarSign className="h-4 w-4 text-green-500" />}
{payment.mode === 'CARTE' && <CreditCard className="h-4 w-4 text-blue-500" />}
{payment.mode === 'CHEQUE' && <FileText className="h-4 w-4 text-purple-500" />}
{payment.mode === 'VIREMENT' && <FileText className="h-4 w-4 text-orange-500" />}
{payment.mode === 'BON_CAISSE' && <FileText className="h-4 w-4 text-cyan-500" />}
<span className="text-sm capitalize">
{payment.mode.toLowerCase().replace('_', ' ')}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{payment.montant.toFixed(2)} </span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removePayment(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
<Separator />
<div className="flex justify-between items-center text-lg">
<span>Reste à payer:</span>
<span className={`font-bold ${remainingToPay <= 0.01 ? 'text-green-600' : 'text-red-600'}`}>
{Math.max(0, remainingToPay).toFixed(2)}
</span>
</div>
<Button
onClick={completeSale}
disabled={cart.length === 0 || remainingToPay > 0.01}
className="w-full"
size="lg"
>
<CheckCircle className="h-5 w-5 mr-2" />
Valider la vente
</Button>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* Sales History */}
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>Historique des ventes</CardTitle>
<CardDescription>Les dernières ventes enregistrées</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>N° Vente</TableHead>
<TableHead>Date</TableHead>
<TableHead>Client</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Montant TTC</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salesHistory.map((sale) => (
<TableRow key={sale.id}>
<TableCell className="font-medium">{sale.numero}</TableCell>
<TableCell>
{new Date(sale.date).toLocaleDateString('fr-FR')}
</TableCell>
<TableCell>
{sale.client
? `${sale.client.prenom} ${sale.client.nom}`
: 'Client anonyme'
}
</TableCell>
<TableCell>{getStatusBadge(sale.statut)}</TableCell>
<TableCell className="text-right font-bold">
{sale.montantTTC.toFixed(2)}
</TableCell>
</TableRow>
))}
{salesHistory.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
Aucune vente enregistrée
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Invoice Dialog */}
<Dialog open={showInvoiceDialog} onOpenChange={setShowInvoiceDialog}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Facture {currentSale?.numero}
</DialogTitle>
<DialogDescription>
Vente complétée avec succès
</DialogDescription>
</DialogHeader>
{currentSale && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-semibold mb-1">OptiqueStock</p>
<p className="text-gray-600">Système de Gestion d'Optique</p>
</div>
<div className="text-right">
<p className="font-semibold">Facture N° {currentSale.numero}</p>
<p className="text-gray-600">
{new Date(currentSale.date).toLocaleDateString('fr-FR')} à{' '}
{new Date(currentSale.date).toLocaleTimeString('fr-FR')}
</p>
</div>
</div>
{currentSale.client && (
<div className="bg-gray-50 p-4 rounded">
<p className="font-semibold mb-1">Client</p>
<p>{currentSale.client.prenom} {currentSale.client.nom}</p>
<p className="text-sm text-gray-600">{currentSale.client.telephone}</p>
</div>
)}
<Separator />
<div className="space-y-2">
<div className="flex justify-between">
<span>Total HT:</span>
<span>{currentSale.montantHT.toFixed(2)} </span>
</div>
<div className="flex justify-between">
<span>TVA:</span>
<span>{currentSale.montantTVA.toFixed(2)} </span>
</div>
{currentSale.remise > 0 && (
<div className="flex justify-between text-red-600">
<span>Remise:</span>
<span>-{currentSale.remise.toFixed(2)} </span>
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total TTC:</span>
<span>{currentSale.montantTTC.toFixed(2)} </span>
</div>
</div>
<div className="flex justify-center pt-4">
{getStatusBadge(currentSale.statut)}
</div>
<Button onClick={() => setShowInvoiceDialog(false)} className="w-full">
Fermer
</Button>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,263 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Loader2, Upload, X as XIcon, Image as ImageIcon } from 'lucide-react'
import { toast } from 'sonner'
interface Fichier {
id: string
nom: string
url: string
taille: number
mimeType: string
}
interface ImageGalleryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
produit?: any
onRefresh?: () => void
}
export function ImageGalleryDialog({
open,
onOpenChange,
produit,
onRefresh,
}: ImageGalleryDialogProps) {
const [images, setImages] = useState<Fichier[]>([])
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
useEffect(() => {
if (open && produit) {
fetchImages()
}
}, [open, produit])
const fetchImages = async () => {
if (!produit) return
try {
setLoading(true)
const response = await fetch(`/api/produits/${produit.id}/images`)
if (!response.ok) throw new Error('Failed to fetch images')
const data = await response.json()
setImages(data)
} catch (error) {
console.error('Error fetching images:', error)
toast.error('Erreur lors du chargement des images')
} finally {
setLoading(false)
}
}
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const file = files[0]
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Veuillez sélectionner un fichier image')
return
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('L\'image ne doit pas dépasser 5MB')
return
}
await uploadImage(file)
}
const uploadImage = async (file: File) => {
if (!produit) return
try {
setUploading(true)
const formData = new FormData()
formData.append('file', file)
formData.append('produitId', produit.id)
const response = await fetch('/api/produits/upload-image', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to upload image')
}
toast.success('Image téléchargée avec succès')
fetchImages()
onRefresh?.()
} catch (error: any) {
console.error('Error uploading image:', error)
toast.error(error.message || 'Erreur lors du téléchargement de l\'image')
} finally {
setUploading(false)
}
}
const handleDeleteImage = async (imageId: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cette image ?')) return
try {
const response = await fetch(`/api/fichiers/${imageId}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Failed to delete image')
toast.success('Image supprimée avec succès')
fetchImages()
onRefresh?.()
} catch (error) {
console.error('Error deleting image:', error)
toast.error('Erreur lors de la suppression de l\'image')
}
}
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]
}
if (!produit) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Galerie d'Images</DialogTitle>
<DialogDescription>
Gérez les images du produit: {produit.designation}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Upload Section */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Ajouter une image</h3>
<p className="text-sm text-gray-500">
Formats acceptés: JPG, PNG, WebP (max 5MB)
</p>
</div>
<div className="flex items-center gap-2">
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
id="image-upload"
disabled={uploading}
/>
<label htmlFor="image-upload">
<Button
asChild
disabled={uploading}
>
<span className="cursor-pointer">
{uploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Téléchargement...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Télécharger
</>
)}
</span>
</Button>
</label>
</div>
</div>
</CardContent>
</Card>
{/* Images Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : images.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ImageIcon className="h-12 w-12 text-gray-300 mb-4" />
<p className="text-gray-500">Aucune image pour ce produit</p>
<p className="text-sm text-gray-400 mt-1">
Téléchargez des images pour illustrer le produit
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((image) => (
<Card key={image.id} className="overflow-hidden group">
<div className="relative aspect-square">
<img
src={image.url}
alt={image.nom}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button
variant="destructive"
size="icon"
onClick={() => handleDeleteImage(image.id)}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
</div>
<CardContent className="p-3">
<p className="text-sm font-medium truncate">{image.nom}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{formatFileSize(image.taille)}
</Badge>
<Badge variant="secondary" className="text-xs">
{image.mimeType.split('/')[1].toUpperCase()}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Fermer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,681 @@
'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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toast } from 'sonner'
import { Loader2, Save } from 'lucide-react'
const produitFormSchema = z.object({
reference: z.string().min(1, 'La référence est requise'),
designation: z.string().min(1, 'La désignation est requise'),
categorie: z.enum(['MONTURE', 'VERRE', 'LENTILLE', 'ACCESSOIRE']),
fournisseurId: z.string().optional(),
prixAchatHT: z.number().min(0, 'Le prix d\'achat doit être positif'),
prixVenteTTC: z.number().min(0, 'Le prix de vente doit être positif'),
tva: z.number().min(0).max(100),
stock: z.number().int().min(0),
stockMin: z.number().int().min(0),
emplacement: z.string().optional(),
marque: z.string().optional(),
typeMonture: z.enum(['COMPLET', 'NATUREL', 'CERCLAGE']).optional(),
typeVerre: z.enum(['SIMPLE', 'BIFOCAL', 'PROGRESSIF']).optional(),
indice: z.number().optional(),
materiau: z.string().optional(),
couleur: z.string().optional(),
dimensions: z.string().optional(),
description: z.string().optional(),
codeBarre: z.string().optional(),
actif: z.boolean().default(true),
})
type ProduitFormValues = z.infer<typeof produitFormSchema>
export interface Fournisseur {
id: string
nom: string
}
interface ProduitFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
produit?: any
onSuccess: () => void
}
export function ProduitFormDialog({
open,
onOpenChange,
produit,
onSuccess,
}: ProduitFormDialogProps) {
const [loading, setLoading] = useState(false)
const [fournisseurs, setFournisseurs] = useState<Fournisseur[]>([])
const [loadingFournisseurs, setLoadingFournisseurs] = useState(false)
const form = useForm<ProduitFormValues>({
resolver: zodResolver(produitFormSchema),
defaultValues: {
reference: '',
designation: '',
categorie: 'MONTURE',
fournisseurId: '',
prixAchatHT: 0,
prixVenteTTC: 0,
tva: 20,
stock: 0,
stockMin: 5,
emplacement: '',
marque: '',
typeMonture: undefined,
typeVerre: undefined,
indice: undefined,
materiau: '',
couleur: '',
dimensions: '',
description: '',
codeBarre: '',
actif: true,
},
})
const categorie = form.watch('categorie')
useEffect(() => {
if (open) {
fetchFournisseurs()
if (produit) {
form.reset({
reference: produit.reference || '',
designation: produit.designation || '',
categorie: produit.categorie || 'MONTURE',
fournisseurId: produit.fournisseurId || '',
prixAchatHT: produit.prixAchatHT || 0,
prixVenteTTC: produit.prixVenteTTC || 0,
tva: produit.tva || 20,
stock: produit.stock || 0,
stockMin: produit.stockMin || 5,
emplacement: produit.emplacement || '',
marque: produit.marque || '',
typeMonture: produit.typeMonture || undefined,
typeVerre: produit.typeVerre || undefined,
indice: produit.indice || undefined,
materiau: produit.materiau || '',
couleur: produit.couleur || '',
dimensions: produit.dimensions || '',
description: produit.description || '',
codeBarre: produit.codeBarre || '',
actif: produit.actif !== undefined ? produit.actif : true,
})
} else {
form.reset()
}
}
}, [open, produit, form])
const fetchFournisseurs = async () => {
try {
setLoadingFournisseurs(true)
const response = await fetch('/api/fournisseurs')
if (!response.ok) throw new Error('Failed to fetch suppliers')
const data = await response.json()
setFournisseurs(data)
} catch (error) {
console.error('Error fetching fournisseurs:', error)
} finally {
setLoadingFournisseurs(false)
}
}
const onSubmit = async (data: ProduitFormValues) => {
try {
setLoading(true)
const url = produit
? `/api/produits/${produit.id}`
: '/api/produits'
const method = produit ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to save product')
}
toast.success(produit ? 'Produit mis à jour avec succès' : 'Produit créé avec succès')
onOpenChange(false)
onSuccess()
} catch (error: any) {
console.error('Error saving produit:', error)
toast.error(error.message || 'Erreur lors de l\'enregistrement du produit')
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{produit ? 'Modifier le Produit' : 'Nouveau Produit'}
</DialogTitle>
<DialogDescription>
{produit
? 'Modifiez les informations du produit existant'
: 'Remplissez les informations pour créer un nouveau produit'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="general">Général</TabsTrigger>
<TabsTrigger value="pricing">Tarification</TabsTrigger>
<TabsTrigger value="stock">Stock</TabsTrigger>
<TabsTrigger value="details">Détails</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="reference"
render={({ field }) => (
<FormItem>
<FormLabel>Référence *</FormLabel>
<FormControl>
<Input placeholder="EX: MNT-001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="designation"
render={({ field }) => (
<FormItem>
<FormLabel>Désignation *</FormLabel>
<FormControl>
<Input placeholder="Nom du produit" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categorie"
render={({ field }) => (
<FormItem>
<FormLabel>Catégorie *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="MONTURE">Monture</SelectItem>
<SelectItem value="VERRE">Verre</SelectItem>
<SelectItem value="LENTILLE">Lentille</SelectItem>
<SelectItem value="ACCESSOIRE">Accessoire</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fournisseurId"
render={({ field }) => (
<FormItem>
<FormLabel>Fournisseur</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un fournisseur" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Aucun fournisseur</SelectItem>
{loadingFournisseurs ? (
<SelectItem value="loading" disabled>
Chargement...
</SelectItem>
) : (
fournisseurs.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nom}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="marque"
render={({ field }) => (
<FormItem>
<FormLabel>Marque</FormLabel>
<FormControl>
<Input placeholder="Marque du produit" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="codeBarre"
render={({ field }) => (
<FormItem>
<FormLabel>Code-barres</FormLabel>
<FormControl>
<Input placeholder="Code-barres (EAN13)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description détaillée du produit..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="actif"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Produit actif</FormLabel>
<FormDescription>
Ce produit sera visible et disponible à la vente
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="pricing" className="space-y-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="prixAchatHT"
render={({ field }) => (
<FormItem>
<FormLabel>Prix Achat HT () *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="prixVenteTTC"
render={({ field }) => (
<FormItem>
<FormLabel>Prix Vente TTC () *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tva"
render={({ field }) => (
<FormItem>
<FormLabel>TVA (%)</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormDescription>Taux de TVA applicable</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{form.watch('prixAchatHT') > 0 && form.watch('prixVenteTTC') > 0 && (
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm font-medium">Marge estimée:</p>
<p className="text-2xl font-bold text-green-600">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(
form.watch('prixVenteTTC') /
(1 + form.watch('tva') / 100) -
form.watch('prixAchatHT')
)}
</p>
<p className="text-xs text-gray-500 mt-1">
{(
((form.watch('prixVenteTTC') /
(1 + form.watch('tva') / 100) -
form.watch('prixAchatHT')) /
form.watch('prixAchatHT')) *
100
).toFixed(1)}
{'% de marge'}
</p>
</div>
)}
</TabsContent>
<TabsContent value="stock" className="space-y-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="stock"
render={({ field }) => (
<FormItem>
<FormLabel>Stock actuel *</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stockMin"
render={({ field }) => (
<FormItem>
<FormLabel>Stock minimum *</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormDescription>
Alerte quand le stock est inférieur
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emplacement"
render={({ field }) => (
<FormItem>
<FormLabel>Emplacement</FormLabel>
<FormControl>
<Input placeholder="EX: Rayon A, Étagère 2" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="details" className="space-y-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{categorie === 'MONTURE' && (
<>
<FormField
control={form.control}
name="typeMonture"
render={({ field }) => (
<FormItem>
<FormLabel>Type de monture</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="COMPLET">Complet</SelectItem>
<SelectItem value="NATUREL">Naturel</SelectItem>
<SelectItem value="CERCLAGE">Cerclage</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dimensions"
render={({ field }) => (
<FormItem>
<FormLabel>Dimensions</FormLabel>
<FormControl>
<Input placeholder="EX: 54-18-140" {...field} />
</FormControl>
<FormDescription>
Largeur-Pont-Branches (mm)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{categorie === 'VERRE' && (
<>
<FormField
control={form.control}
name="typeVerre"
render={({ field }) => (
<FormItem>
<FormLabel>Type de verre</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SIMPLE">Simple foyer</SelectItem>
<SelectItem value="BIFOCAL">Bifocal</SelectItem>
<SelectItem value="PROGRESSIF">Progressif</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="indice"
render={({ field }) => (
<FormItem>
<FormLabel>Indice de réfraction</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="EX: 1.5, 1.6, 1.67"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="materiau"
render={({ field }) => (
<FormItem>
<FormLabel>Matériau</FormLabel>
<FormControl>
<Input placeholder="EX: Acétate, Métal, Titane" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="couleur"
render={({ field }) => (
<FormItem>
<FormLabel>Couleur</FormLabel>
<FormControl>
<Input placeholder="EX: Noir, Rouge, Bleu marine" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,492 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Plus,
Search,
Edit,
Trash2,
Image as ImageIcon,
QrCode,
AlertTriangle,
Package,
Filter,
X
} from 'lucide-react'
import { toast } from 'sonner'
import { ProduitFormDialog } from './ProduitFormDialog'
import { QRCodeDialog } from './QRCodeDialog'
import { ImageGalleryDialog } from './ImageGalleryDialog'
export type CategorieProduit = 'MONTURE' | 'VERRE' | 'LENTILLE' | 'ACCESSOIRE'
export interface Produit {
id: string
reference: string
designation: string
categorie: CategorieProduit
fournisseurId?: string | null
fournisseurNom?: string | null
prixAchatHT: number
prixVenteTTC: number
tva: number
stock: number
stockMin: number
emplacement?: string | null
marque?: string | null
typeMonture?: string | null
typeVerre?: string | null
indice?: number | null
materiau?: string | null
couleur?: string | null
dimensions?: string | null
description?: string | null
codeBarre?: string | null
actif: boolean
createdAt: string
updatedAt: string
images?: Fichier[]
}
export interface Fichier {
id: string
nom: string
url: string
taille: number
mimeType: string
}
export interface ProduitFilters {
search: string
categorie: string
stockStatus: string
actif: string
}
export function ProduitListe() {
const [produits, setProduits] = useState<Produit[]>([])
const [loading, setLoading] = useState(true)
const [selectedProduit, setSelectedProduit] = useState<Produit | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [isQrOpen, setIsQrOpen] = useState(false)
const [isImageOpen, setIsImageOpen] = useState(false)
const [filters, setFilters] = useState<ProduitFilters>({
search: '',
categorie: 'all',
stockStatus: 'all',
actif: 'all'
})
const fetchProduits = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search)
if (filters.categorie !== 'all') params.append('categorie', filters.categorie)
if (filters.stockStatus !== 'all') params.append('stockStatus', filters.stockStatus)
if (filters.actif !== 'all') params.append('actif', filters.actif === 'true' ? 'true' : 'false')
const response = await fetch(`/api/produits?${params.toString()}`)
if (!response.ok) throw new Error('Failed to fetch products')
const data = await response.json()
setProduits(data)
} catch (error) {
console.error('Error fetching produits:', error)
toast.error('Erreur lors du chargement des produits')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchProduits()
}, [filters])
const handleDelete = async (id: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce produit ?')) return
try {
const response = await fetch(`/api/produits/${id}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Failed to delete product')
toast.success('Produit supprimé avec succès')
fetchProduits()
} catch (error) {
console.error('Error deleting produit:', error)
toast.error('Erreur lors de la suppression du produit')
}
}
const handleToggleActif = async (id: string, currentStatus: boolean) => {
try {
const response = await fetch(`/api/produits/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actif: !currentStatus }),
})
if (!response.ok) throw new Error('Failed to update product')
toast.success('Statut du produit mis à jour')
fetchProduits()
} catch (error) {
console.error('Error updating produit:', error)
toast.error('Erreur lors de la mise à jour du produit')
}
}
const getStockStatus = (produit: Produit) => {
if (produit.stock <= 0) return { label: 'Rupture', variant: 'destructive' as const }
if (produit.stock < produit.stockMin) return { label: 'Stock bas', variant: 'destructive' as const }
if (produit.stock < produit.stockMin * 2) return { label: 'Stock faible', variant: 'default' as const }
return { label: 'En stock', variant: 'secondary' as const }
}
const getCategorieBadgeColor = (categorie: string) => {
switch (categorie) {
case 'MONTURE': return 'bg-blue-500'
case 'VERRE': return 'bg-green-500'
case 'LENTILLE': return 'bg-purple-500'
case 'ACCESSOIRE': return 'bg-orange-500'
default: return 'bg-gray-500'
}
}
const hasActiveFilters = filters.search || filters.categorie !== 'all' || filters.stockStatus !== 'all' || filters.actif !== 'all'
const filteredProduits = produits
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">Gestion des Produits</h2>
<p className="text-sm text-gray-500 mt-1">
{filteredProduits.length} produit{filteredProduits.length !== 1 ? 's' : ''} dans le catalogue
</p>
</div>
<Button onClick={() => { setSelectedProduit(null); setIsFormOpen(true); }} className="gap-2">
<Plus className="h-4 w-4" />
Nouveau Produit
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Total Produits</p>
<p className="text-2xl font-bold">{produits.length}</p>
</div>
<Package className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Stock Bas</p>
<p className="text-2xl font-bold text-red-600">
{produits.filter(p => p.stock < p.stockMin).length}
</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Valeur Stock HT</p>
<p className="text-2xl font-bold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
produits.reduce((acc, p) => acc + (p.prixAchatHT * p.stock), 0)
)}
</p>
</div>
<div className="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center">
<span className="text-green-600 font-bold text-sm"></span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Produits Actifs</p>
<p className="text-2xl font-bold text-green-600">
{produits.filter(p => p.actif).length}
</p>
</div>
<div className={`h-8 w-8 rounded-full ${produits.filter(p => p.actif).length > 0 ? 'bg-green-100' : 'bg-gray-100'} flex items-center justify-center`}>
<span className={`${produits.filter(p => p.actif).length > 0 ? 'text-green-600' : 'text-gray-400'} font-bold text-sm`}>
{produits.filter(p => p.actif).length}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="h-5 w-5" />
Filtres
</CardTitle>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => setFilters({ search: '', categorie: 'all', stockStatus: 'all', actif: 'all' })}
className="gap-2"
>
<X className="h-4 w-4" />
Effacer
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<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..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="pl-10"
/>
</div>
<Select value={filters.categorie} onValueChange={(value) => setFilters({ ...filters, categorie: value })}>
<SelectTrigger>
<SelectValue placeholder="Catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les catégories</SelectItem>
<SelectItem value="MONTURE">Montures</SelectItem>
<SelectItem value="VERRE">Verres</SelectItem>
<SelectItem value="LENTILLE">Lentilles</SelectItem>
<SelectItem value="ACCESSOIRE">Accessoires</SelectItem>
</SelectContent>
</Select>
<Select value={filters.stockStatus} onValueChange={(value) => setFilters({ ...filters, stockStatus: value })}>
<SelectTrigger>
<SelectValue placeholder="État du stock" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les états</SelectItem>
<SelectItem value="low">Stock bas</SelectItem>
<SelectItem value="ok">En stock</SelectItem>
<SelectItem value="out">Rupture</SelectItem>
</SelectContent>
</Select>
<Select value={filters.actif} onValueChange={(value) => setFilters({ ...filters, actif: value })}>
<SelectTrigger>
<SelectValue placeholder="Statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="true">Actifs</SelectItem>
<SelectItem value="false">Inactifs</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Products Table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Référence</TableHead>
<TableHead>Désignation</TableHead>
<TableHead>Catégorie</TableHead>
<TableHead>Marque</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Prix Achat HT</TableHead>
<TableHead className="text-right">Prix Vente TTC</TableHead>
<TableHead>Emplacement</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</TableCell>
</TableRow>
) : filteredProduits.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-8 text-gray-500">
Aucun produit trouvé
</TableCell>
</TableRow>
) : (
filteredProduits.map((produit) => {
const stockStatus = getStockStatus(produit)
return (
<TableRow key={produit.id} className={!produit.actif ? 'opacity-50' : ''}>
<TableCell className="font-medium">{produit.reference}</TableCell>
<TableCell>
<div className="font-medium">{produit.designation}</div>
{produit.codeBarre && (
<div className="text-xs text-gray-500 mt-1">
Code-barres: {produit.codeBarre}
</div>
)}
</TableCell>
<TableCell>
<Badge className={`${getCategorieBadgeColor(produit.categorie)} text-white`}>
{produit.categorie}
</Badge>
</TableCell>
<TableCell>{produit.marque || '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<span className={`font-semibold ${produit.stock < produit.stockMin ? 'text-red-600' : ''}`}>
{produit.stock}
</span>
{produit.stock < produit.stockMin && (
<AlertTriangle className="h-4 w-4 text-red-500" />
)}
</div>
<div className="text-xs text-gray-500">Min: {produit.stockMin}</div>
</TableCell>
<TableCell className="text-right font-mono">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(produit.prixAchatHT)}
</TableCell>
<TableCell className="text-right font-mono">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(produit.prixVenteTTC)}
</TableCell>
<TableCell>{produit.emplacement || '-'}</TableCell>
<TableCell>
<Badge variant={stockStatus.variant}>
{stockStatus.label}
</Badge>
{!produit.actif && (
<Badge variant="outline" className="ml-1 mt-1">
Inactif
</Badge>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedProduit(produit); setIsFormOpen(true); }}
title="Modifier"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedProduit(produit); setIsQrOpen(true); }}
title="QR Code"
>
<QrCode className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedProduit(produit); setIsImageOpen(true); }}
title="Images"
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActif(produit.id, produit.actif)}
title={produit.actif ? "Désactiver" : "Activer"}
>
{produit.actif ? (
<X className="h-4 w-4 text-red-500" />
) : (
<div className="h-4 w-4 rounded-full bg-green-500" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(produit.id)}
title="Supprimer"
className="text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Dialogs */}
<ProduitFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
produit={selectedProduit}
onSuccess={fetchProduits}
/>
<QRCodeDialog
open={isQrOpen}
onOpenChange={setIsQrOpen}
produit={selectedProduit}
/>
<ImageGalleryDialog
open={isImageOpen}
onOpenChange={setIsImageOpen}
produit={selectedProduit}
onRefresh={fetchProduits}
/>
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Loader2, Download, Printer, CheckCircle } from 'lucide-react'
import { toast } from 'sonner'
import { generateProductQRCode } from '@/lib/qr-code'
interface QRCodeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
produit?: any
}
export function QRCodeDialog({ open, onOpenChange, produit }: QRCodeDialogProps) {
const [qrCodeData, setQrCodeData] = useState<string>('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (open && produit) {
generateQR()
}
}, [open, produit])
const generateQR = async () => {
if (!produit) return
try {
setLoading(true)
const qrData = await generateProductQRCode(produit.reference, produit.designation)
setQrCodeData(qrData)
} catch (error) {
console.error('Error generating QR code:', error)
toast.error('Erreur lors de la génération du QR code')
} finally {
setLoading(false)
}
}
const handleDownload = () => {
if (!qrCodeData) return
const link = document.createElement('a')
link.href = qrCodeData
link.download = `qr-${produit?.reference || 'produit'}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('QR code téléchargé')
}
const handlePrint = () => {
if (!qrCodeData) return
const printWindow = window.open('', '_blank')
if (!printWindow) return
const printContent = `
<!DOCTYPE html>
<html>
<head>
<title>QR Code - ${produit?.reference}</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.qr-label {
border: 2px solid #000;
padding: 20px;
text-align: center;
width: 300px;
}
.qr-label img {
width: 200px;
height: 200px;
}
.qr-label h2 {
margin: 10px 0 5px;
font-size: 18px;
}
.qr-label p {
margin: 5px 0;
font-size: 14px;
}
.qr-label .reference {
font-weight: bold;
font-size: 16px;
}
</style>
</head>
<body>
<div class="qr-label">
<img src="${qrCodeData}" alt="QR Code" />
<h2>${produit?.designation || 'Produit'}</h2>
<p class="reference">${produit?.reference || ''}</p>
${produit?.codeBarre ? `<p>Code-barres: ${produit.codeBarre}</p>` : ''}
${produit?.prixVenteTTC ? `<p>Prix: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(produit.prixVenteTTC)}</p>` : ''}
</div>
</body>
</html>
`
printWindow.document.write(printContent)
printWindow.document.close()
printWindow.focus()
printWindow.print()
toast.success('Impression lancée')
}
if (!produit) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>QR Code du Produit</DialogTitle>
<DialogDescription>
Générez et imprimez le QR code pour l'étiquetage
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<Card>
<CardContent className="p-6">
<div className="flex flex-col items-center space-y-4">
{qrCodeData && (
<img
src={qrCodeData}
alt="QR Code"
className="border-2 border-gray-200 rounded-lg p-2"
/>
)}
<div className="text-center space-y-1">
<p className="font-semibold text-lg">{produit.designation}</p>
<p className="text-sm text-gray-500">Réf: {produit.reference}</p>
{produit.codeBarre && (
<p className="text-xs text-gray-400">EAN: {produit.codeBarre}</p>
)}
{produit.prixVenteTTC && (
<p className="text-lg font-bold text-green-600 mt-2">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(produit.prixVenteTTC)}
</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={handleDownload}
disabled={!qrCodeData || loading}
className="flex-1"
>
<Download className="mr-2 h-4 w-4" />
Télécharger
</Button>
<Button
onClick={handlePrint}
disabled={!qrCodeData || loading}
className="flex-1"
>
<Printer className="mr-2 h-4 w-4" />
Imprimer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,658 @@
'use client'
import React, { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Legend, ResponsiveContainer } from 'recharts'
import {
TrendingUp,
TrendingDown,
DollarSign,
Users,
Package,
AlertTriangle,
Download,
Calendar,
ArrowRight
} from 'lucide-react'
import { toast } from 'sonner'
import { format, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfYear, endOfYear, subDays } from 'date-fns'
import { fr } from 'date-fns/locale'
// Types
interface KPICardProps {
title: string
value: string | number
change?: number
icon: React.ReactNode
trend?: 'up' | 'down' | 'neutral'
}
interface DashboardData {
totalSales: {
today: number
week: number
month: number
year: number
}
revenue: {
htToday: number
ttcToday: number
htMonth: number
ttcMonth: number
}
totalClients: number
topProducts: {
designation: string
quantity: number
revenue: number
}[]
lowStockItems: {
id: string
reference: string
designation: string
stock: number
stockMin: number
}[]
pendingWorkshopOrders: number
}
interface SalesReportData {
salesByDate: {
date: string
sales: number
revenue: number
}[]
salesByCategory: {
category: string
count: number
revenue: number
}[]
salesByEmployee: {
employee: string
sales: number
revenue: number
}[]
salesByPaymentMethod: {
method: string
count: number
amount: number
}[]
}
interface InventoryReportData {
stockValuation: {
totalValue: number
byCategory: {
category: string
value: number
count: number
}[]
}
lowStockItems: {
id: string
reference: string
designation: string
category: string
stock: number
stockMin: number
value: number
}[]
categoryBreakdown: {
category: string
totalProducts: number
activeProducts: number
totalStock: number
stockValue: number
}[]
}
// KPI Card Component
function KPICard({ title, value, change, icon, trend = 'neutral' }: KPICardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{typeof value === 'number' ? value.toLocaleString() : value}</div>
{change !== undefined && (
<p className={`text-xs flex items-center gap-1 mt-1 ${trend === 'up' ? 'text-green-600' : trend === 'down' ? 'text-red-600' : 'text-muted-foreground'}`}>
{trend === 'up' && <TrendingUp className="h-3 w-3" />}
{trend === 'down' && <TrendingDown className="h-3 w-3" />}
{change > 0 ? '+' : ''}{change.toFixed(1)}% par rapport à hier
</p>
)}
</CardContent>
</Card>
)
}
// Chart colors
const CHART_COLORS = {
sales: '#10b981',
revenue: '#3b82f6',
monture: '#8b5cf6',
verre: '#06b6d4',
lentille: '#f59e0b',
accessoire: '#ec4899',
especes: '#10b981',
carte: '#3b82f6',
cheque: '#f59e0b',
virement: '#8b5cf6',
bonCaisse: '#ec4899',
}
export default function ReportsModule() {
const [activeTab, setActiveTab] = useState('dashboard')
const [dateRange, setDateRange] = useState<'today' | 'week' | 'month' | 'year' | 'custom'>('month')
const [loading, setLoading] = useState(true)
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
const [salesData, setSalesData] = useState<SalesReportData | null>(null)
const [inventoryData, setInventoryData] = useState<InventoryReportData | null>(null)
// Fetch dashboard data
const fetchDashboardData = async () => {
try {
setLoading(true)
const response = await fetch('/api/reports/dashboard')
if (!response.ok) throw new Error('Failed to fetch dashboard data')
const data = await response.json()
setDashboardData(data)
} catch (error) {
console.error('Error fetching dashboard data:', error)
toast.error('Erreur lors du chargement des données du tableau de bord')
} finally {
setLoading(false)
}
}
// Fetch sales report data
const fetchSalesData = async () => {
try {
const response = await fetch(`/api/reports/sales?range=${dateRange}`)
if (!response.ok) throw new Error('Failed to fetch sales data')
const data = await response.json()
setSalesData(data)
} catch (error) {
console.error('Error fetching sales data:', error)
toast.error('Erreur lors du chargement des données de ventes')
}
}
// Fetch inventory report data
const fetchInventoryData = async () => {
try {
const response = await fetch('/api/reports/inventory')
if (!response.ok) throw new Error('Failed to fetch inventory data')
const data = await response.json()
setInventoryData(data)
} catch (error) {
console.error('Error fetching inventory data:', error)
toast.error('Erreur lors du chargement des données de stock')
}
}
// Export data to CSV
const exportToCSV = async (type: 'sales' | 'inventory' | 'lowStock') => {
try {
const response = await fetch(`/api/reports/export/${type}?range=${dateRange}`)
if (!response.ok) throw new Error('Failed to export data')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${type}_${format(new Date(), 'yyyy-MM-dd')}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success('Export CSV réussi')
} catch (error) {
console.error('Error exporting data:', error)
toast.error('Erreur lors de l\'export des données')
}
}
// Load data based on active tab
useEffect(() => {
if (activeTab === 'dashboard') {
fetchDashboardData()
} else if (activeTab === 'sales') {
fetchSalesData()
} else if (activeTab === 'inventory') {
fetchInventoryData()
}
}, [activeTab, dateRange])
// Initial data load
useEffect(() => {
fetchDashboardData()
}, [])
const chartConfig = {
sales: { label: 'Ventes', color: CHART_COLORS.sales },
revenue: { label: 'Chiffre d\'affaires', color: CHART_COLORS.revenue },
monture: { label: 'Montures', color: CHART_COLORS.monture },
verre: { label: 'Verres', color: CHART_COLORS.verre },
lentille: { label: 'Lentilles', color: CHART_COLORS.lentille },
accessoire: { label: 'Accessoires', color: CHART_COLORS.accessoire },
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Rapports</h1>
<p className="text-muted-foreground">Tableau de bord et statistiques de votre magasin</p>
</div>
<div className="flex items-center gap-2">
<Select value={dateRange} onValueChange={(value: any) => setDateRange(value)}>
<SelectTrigger className="w-[180px]">
<Calendar className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Aujourd'hui</SelectItem>
<SelectItem value="week">Cette semaine</SelectItem>
<SelectItem value="month">Ce mois</SelectItem>
<SelectItem value="year">Cette année</SelectItem>
</SelectContent>
</Select>
{activeTab !== 'dashboard' && (
<Button onClick={() => exportToCSV(activeTab === 'sales' ? 'sales' : activeTab === 'inventory' ? 'inventory' : 'lowStock')}>
<Download className="mr-2 h-4 w-4" />
Exporter CSV
</Button>
)}
</div>
</div>
{/* Main Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="dashboard">Tableau de bord</TabsTrigger>
<TabsTrigger value="sales">Rapports de ventes</TabsTrigger>
<TabsTrigger value="inventory">Rapports de stock</TabsTrigger>
</TabsList>
{/* Dashboard Tab */}
<TabsContent value="dashboard" className="space-y-4">
{loading && !dashboardData ? (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : (
<>
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KPICard
title="Ventes du jour"
value={dashboardData?.totalSales.today || 0}
icon={<Package className="h-4 w-4" />}
/>
<KPICard
title="CA du jour (TTC)"
value={`${dashboardData?.revenue.ttcToday.toFixed(2) || 0} €`}
icon={<DollarSign className="h-4 w-4" />}
/>
<KPICard
title="Clients total"
value={dashboardData?.totalClients || 0}
icon={<Users className="h-4 w-4" />}
/>
<KPICard
title="Commandes atelier en attente"
value={dashboardData?.pendingWorkshopOrders || 0}
icon={<AlertTriangle className="h-4 w-4" />}
trend={dashboardData?.pendingWorkshopOrders && dashboardData.pendingWorkshopOrders > 0 ? 'down' : 'neutral'}
/>
</div>
{/* Additional KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KPICard
title="Ventes de la semaine"
value={dashboardData?.totalSales.week || 0}
icon={<TrendingUp className="h-4 w-4" />}
/>
<KPICard
title="CA de la semaine (TTC)"
value={`${dashboardData?.revenue.htMonth.toFixed(2) || 0} €`}
icon={<DollarSign className="h-4 w-4" />}
/>
<KPICard
title="Ventes du mois"
value={dashboardData?.totalSales.month || 0}
icon={<TrendingUp className="h-4 w-4" />}
/>
<KPICard
title="CA du mois (TTC)"
value={`${dashboardData?.revenue.ttcMonth.toFixed(2) || 0} €`}
icon={<DollarSign className="h-4 w-4" />}
/>
</div>
{/* Top Products and Low Stock */}
<div className="grid gap-4 md:grid-cols-2">
{/* Top Selling Products */}
<Card>
<CardHeader>
<CardTitle>Top 5 des produits vendus</CardTitle>
<CardDescription>Les produits les plus populaires ce mois</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
<div className="space-y-4">
{dashboardData?.topProducts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">Aucune vente ce mois</p>
) : (
dashboardData?.topProducts.map((product, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex-1">
<p className="font-medium">{product.designation}</p>
<p className="text-sm text-muted-foreground">{product.quantity} vendus</p>
</div>
<div className="text-right">
<p className="font-bold">{product.revenue.toFixed(2)} €</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Low Stock Alerts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Alertes de stock
<Badge variant="destructive">{dashboardData?.lowStockItems.length || 0}</Badge>
</CardTitle>
<CardDescription>Produits en dessous du stock minimum</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
<div className="space-y-3">
{dashboardData?.lowStockItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">Aucune alerte de stock</p>
) : (
dashboardData?.lowStockItems.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex-1">
<p className="font-medium text-destructive">{item.designation}</p>
<p className="text-sm text-muted-foreground">{item.reference}</p>
</div>
<Badge variant="destructive" className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{item.stock} / {item.stockMin}
</Badge>
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</>
)}
</TabsContent>
{/* Sales Reports Tab */}
<TabsContent value="sales" className="space-y-4">
{loading && !salesData ? (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : (
<>
{/* Sales by Date Chart */}
<Card>
<CardHeader>
<CardTitle>Évolution des ventes</CardTitle>
<CardDescription>Ventes et chiffre d'affaires par période</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={salesData?.salesByDate || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<ChartTooltip content={<ChartTooltipContent />} />
<Legend />
<Bar yAxisId="left" dataKey="sales" fill={CHART_COLORS.sales} name="Ventes" />
<Bar yAxisId="right" dataKey="revenue" fill={CHART_COLORS.revenue} name="CA (€)" />
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{/* Sales by Category Pie Chart */}
<Card>
<CardHeader>
<CardTitle>Ventes par catégorie</CardTitle>
<CardDescription>Répartition des ventes par type de produit</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={salesData?.salesByCategory || []}
dataKey="revenue"
nameKey="category"
cx="50%"
cy="50%"
labelLine={false}
label={({ category, percent }) => `${category} ${(percent * 100).toFixed(0)}%`}
>
{salesData?.salesByCategory.map((entry, index) => (
<Cell key={`cell-${index}`} fill={Object.values(CHART_COLORS)[index % Object.values(CHART_COLORS).length]} />
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent />} />
</PieChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
{/* Sales by Payment Method */}
<Card>
<CardHeader>
<CardTitle>Mode de paiement</CardTitle>
<CardDescription>Répartition des paiements</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={salesData?.salesByPaymentMethod || []} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="method" type="category" width={100} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="amount" fill={CHART_COLORS.revenue} name="Montant (€)" />
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
</div>
{/* Sales by Employee Table */}
<Card>
<CardHeader>
<CardTitle>Ventes par employé</CardTitle>
<CardDescription>Performance de l'équipe</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Employé</TableHead>
<TableHead className="text-right">Nombre de ventes</TableHead>
<TableHead className="text-right">Chiffre d'affaires</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{salesData?.salesByEmployee.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
Aucune donnée disponible
</TableCell>
</TableRow>
) : (
salesData?.salesByEmployee.map((employee, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{employee.employee}</TableCell>
<TableCell className="text-right">{employee.sales}</TableCell>
<TableCell className="text-right font-bold">{employee.revenue.toFixed(2)} </TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
</TabsContent>
{/* Inventory Reports Tab */}
<TabsContent value="inventory" className="space-y-4">
{loading && !inventoryData ? (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : (
<>
{/* Stock Valuation */}
<Card>
<CardHeader>
<CardTitle>Valorisation du stock</CardTitle>
<CardDescription>Valeur totale du stock par catégorie</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-6">
<p className="text-sm text-muted-foreground">Valeur totale du stock</p>
<p className="text-3xl font-bold">{inventoryData?.stockValuation.totalValue.toFixed(2)} HT</p>
</div>
<ChartContainer config={chartConfig} className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={inventoryData?.stockValuation.byCategory || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="category" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="value" fill={CHART_COLORS.monture} name="Valeur (€ HT)" />
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{/* Category Breakdown Table */}
<Card>
<CardHeader>
<CardTitle>Répartition par catégorie</CardTitle>
<CardDescription>Détails du stock par type de produit</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Catégorie</TableHead>
<TableHead className="text-right">Produits</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Valeur</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inventoryData?.categoryBreakdown.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
Aucune donnée disponible
</TableCell>
</TableRow>
) : (
inventoryData?.categoryBreakdown.map((cat) => (
<TableRow key={cat.category}>
<TableCell className="font-medium">{cat.category}</TableCell>
<TableCell className="text-right">{cat.activeProducts} / {cat.totalProducts}</TableCell>
<TableCell className="text-right">{cat.totalStock}</TableCell>
<TableCell className="text-right font-bold">{cat.stockValue.toFixed(2)} </TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
{/* Low Stock Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Produits en stock faible
<Badge variant="destructive">{inventoryData?.lowStockItems.length || 0}</Badge>
</CardTitle>
<CardDescription>Produits nécessitant un réapprovisionnement</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
<div className="space-y-2">
{inventoryData?.lowStockItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">Tous les stocks sont OK</p>
) : (
inventoryData?.lowStockItems.map((item) => (
<div key={item.id} className="p-3 border rounded-lg flex items-center justify-between hover:bg-muted/50">
<div className="flex-1">
<p className="font-medium text-sm">{item.designation}</p>
<p className="text-xs text-muted-foreground">{item.reference} {item.category}</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-destructive">{item.stock} unités</p>
<p className="text-xs text-muted-foreground">Min: {item.stockMin}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,356 @@
'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 { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Phone,
Mail,
MapPin,
Building2,
Calendar,
FileText,
Package,
Edit,
Power,
PowerOff,
} from 'lucide-react'
interface Supplier {
id: string
nom: string
contact: string | null
email: string | null
telephone: string | null
adresse: string | null
ville: string | null
codePostal: string | null
notes: string | null
actif: boolean
createdAt: string
updatedAt: string
}
interface Product {
id: string
reference: string
designation: string
categorie: string
stock: number
prixVenteTTC: number
}
interface Invoice {
id: string
numero: string
date: string
montantHT: number
montantTVA: number
montantTTC: number
statut: string
}
interface SupplierDetailProps {
supplier: Supplier
onClose: () => void
onUpdate: () => void
onEdit: () => void
}
export function SupplierDetail({ supplier, onClose, onUpdate, onEdit }: SupplierDetailProps) {
const [products, setProducts] = useState<Product[]>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchSupplierData()
}, [supplier.id])
const fetchSupplierData = async () => {
setLoading(true)
try {
// Fetch products from this supplier
const productsResponse = await fetch(`/api/produits?fournisseurId=${supplier.id}`)
if (productsResponse.ok) {
const productsData = await productsResponse.json()
setProducts(Array.isArray(productsData) ? productsData : [])
}
// Fetch invoices from this supplier
const invoicesResponse = await fetch(`/api/fournisseurs/${supplier.id}/factures`)
if (invoicesResponse.ok) {
const invoicesData = await invoicesResponse.json()
setInvoices(Array.isArray(invoicesData) ? invoicesData : [])
}
} catch (error) {
console.error('Error fetching supplier data:', error)
} finally {
setLoading(false)
}
}
const handleToggleStatus = async () => {
try {
const response = await fetch(`/api/fournisseurs/${supplier.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...supplier,
actif: !supplier.actif,
}),
})
if (response.ok) {
onUpdate()
}
} catch (error) {
console.error('Error toggling supplier status:', error)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(amount)
}
const totalPurchaseAmount = invoices.reduce((sum, inv) => sum + inv.montantTTC, 0)
return (
<div className="space-y-6">
{/* Actions */}
<div className="flex gap-2">
<Button onClick={onEdit} variant="outline" className="gap-2">
<Edit className="h-4 w-4" />
Modifier
</Button>
<Button
onClick={handleToggleStatus}
variant={supplier.actif ? "destructive" : "default"}
className="gap-2"
>
{supplier.actif ? (
<>
<PowerOff className="h-4 w-4" />
Désactiver
</>
) : (
<>
<Power className="h-4 w-4" />
Activer
</>
)}
</Button>
</div>
{/* Supplier Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Informations du fournisseur
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold">{supplier.nom}</h3>
{supplier.contact && (
<p className="text-sm text-gray-500">Contact: {supplier.contact}</p>
)}
</div>
<Badge
variant={supplier.actif ? 'default' : 'secondary'}
className={
supplier.actif
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}
>
{supplier.actif ? 'Actif' : 'Inactif'}
</Badge>
</div>
<Separator />
<div className="space-y-3">
{supplier.telephone && (
<div className="flex items-center gap-3">
<Phone className="h-4 w-4 text-gray-500" />
<span>{supplier.telephone}</span>
</div>
)}
{supplier.email && (
<div className="flex items-center gap-3">
<Mail className="h-4 w-4 text-gray-500" />
<span>{supplier.email}</span>
</div>
)}
{(supplier.adresse || supplier.ville || supplier.codePostal) && (
<div className="flex items-start gap-3">
<MapPin className="h-4 w-4 text-gray-500 mt-0.5" />
<div className="space-y-1">
{supplier.adresse && <div>{supplier.adresse}</div>}
<div>
{supplier.codePostal && `${supplier.codePostal} `}
{supplier.ville}
</div>
</div>
</div>
)}
<div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-gray-500" />
<span className="text-sm text-gray-500">
Créé le {formatDate(supplier.createdAt)}
</span>
</div>
</div>
{supplier.notes && (
<>
<Separator />
<div>
<h4 className="font-medium mb-2">Notes</h4>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{supplier.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
{/* Statistics */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Produits</p>
<p className="text-2xl font-bold">{products.length}</p>
</div>
<Package className="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Factures</p>
<p className="text-2xl font-bold">{invoices.length}</p>
</div>
<FileText className="h-8 w-8 text-orange-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Achats</p>
<p className="text-2xl font-bold text-green-600">{formatCurrency(totalPurchaseAmount)}</p>
</div>
<Building2 className="h-8 w-8 text-green-500" />
</div>
</CardContent>
</Card>
</div>
{/* Products List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Produits fournis ({products.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-gray-500">Chargement...</div>
) : products.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Aucun produit associé à ce fournisseur
</div>
) : (
<ScrollArea className="h-[300px]">
<div className="divide-y">
{products.map((product) => (
<div key={product.id} className="p-4 hover:bg-gray-50 flex items-center justify-between">
<div>
<div className="font-medium">{product.designation}</div>
<div className="text-sm text-gray-500">
{product.reference} {product.categorie}
</div>
</div>
<div className="text-right">
<div className="font-medium">{formatCurrency(product.prixVenteTTC)}</div>
<div className="text-sm text-gray-500">Stock: {product.stock}</div>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Purchase History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Historique des achats ({invoices.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-gray-500">Chargement...</div>
) : invoices.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Aucune facture d'achat associée à ce fournisseur
</div>
) : (
<ScrollArea className="h-[300px]">
<div className="divide-y">
{invoices.map((invoice) => (
<div key={invoice.id} className="p-4 hover:bg-gray-50">
<div className="flex items-center justify-between mb-2">
<div className="font-medium">{invoice.numero}</div>
<Badge
variant={invoice.statut === 'VALIDE' ? 'default' : 'secondary'}
className={
invoice.statut === 'VALIDE'
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200'
}
>
{invoice.statut === 'VALIDE' ? 'Validé' : 'Brouillon'}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<div className="text-gray-500">{formatDate(invoice.date)}</div>
<div className="font-medium">{formatCurrency(invoice.montantTTC)}</div>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,298 @@
'use client'
import { useState } 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 { Switch } from '@/components/ui/switch'
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 supplierSchema = z.object({
nom: z.string().min(1, 'Le nom est requis'),
contact: z.string().optional(),
email: z.string().email('Email invalide').optional().or(z.literal('')),
telephone: z.string().optional(),
adresse: z.string().optional(),
ville: z.string().optional(),
codePostal: z.string().optional(),
notes: z.string().optional(),
actif: z.boolean().default(true),
})
type SupplierFormValues = z.infer<typeof supplierSchema>
interface Supplier {
id: string
nom: string
contact: string | null
email: string | null
telephone: string | null
adresse: string | null
ville: string | null
codePostal: string | null
notes: string | null
actif: boolean
createdAt: string
updatedAt: string
}
interface SupplierFormProps {
supplier?: Supplier | null
onSave: () => void
onCancel: () => void
}
export function SupplierForm({ supplier, onSave, onCancel }: SupplierFormProps) {
const [loading, setLoading] = useState(false)
const form = useForm<SupplierFormValues>({
resolver: zodResolver(supplierSchema),
defaultValues: {
nom: supplier?.nom || '',
contact: supplier?.contact || '',
email: supplier?.email || '',
telephone: supplier?.telephone || '',
adresse: supplier?.adresse || '',
ville: supplier?.ville || '',
codePostal: supplier?.codePostal || '',
notes: supplier?.notes || '',
actif: supplier?.actif ?? true,
},
})
const onSubmit = async (data: SupplierFormValues) => {
setLoading(true)
try {
const url = supplier ? `/api/fournisseurs/${supplier.id}` : '/api/fournisseurs'
const method = supplier ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
nom: data.nom,
contact: data.contact || null,
email: data.email || null,
telephone: data.telephone || null,
adresse: data.adresse || null,
ville: data.ville || null,
codePostal: data.codePostal || null,
notes: data.notes || null,
actif: data.actif,
}),
})
if (response.ok) {
onSave()
} else {
const error = await response.json()
console.error('Error saving supplier:', error)
alert('Erreur lors de la sauvegarde du fournisseur: ' + (error.error || 'Erreur inconnue'))
}
} catch (error) {
console.error('Error saving supplier:', error)
alert('Erreur lors de la sauvegarde du fournisseur')
} finally {
setLoading(false)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Informations générales</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="nom"
render={({ field }) => (
<FormItem>
<FormLabel>Nom de l'entreprise *</FormLabel>
<FormControl>
<Input placeholder="Optique Vision SAS" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contact"
render={({ field }) => (
<FormItem>
<FormLabel>Contact principal</FormLabel>
<FormControl>
<Input placeholder="M. Jean Dupont" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Coordonnées</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="0123456789" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="contact@fournisseur.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 Zone Industrielle" {...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="actif"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Statut actif</FormLabel>
<p className="text-sm text-gray-500">
Un fournisseur inactif ne peut plus être sélectionné pour les achats
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Conditions de paiement, délais de livraison, remarques..."
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" />}
{supplier ? 'Mettre à jour' : 'Créer le fournisseur'}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,479 @@
'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,
Building2,
Power,
PowerOff,
Trash2,
} from 'lucide-react'
import { SupplierForm } from './SupplierForm'
import { SupplierDetail } from './SupplierDetail'
import { toast } from 'sonner'
interface Supplier {
id: string
nom: string
contact: string | null
email: string | null
telephone: string | null
adresse: string | null
ville: string | null
codePostal: string | null
notes: string | null
actif: boolean
createdAt: string
updatedAt: string
_count?: {
produits: number
facturesAchat: number
}
}
export function SupplierList() {
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [filteredSuppliers, setFilteredSuppliers] = useState<Supplier[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
const [loading, setLoading] = useState(true)
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null)
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
useEffect(() => {
fetchSuppliers()
}, [])
useEffect(() => {
filterSuppliers()
}, [searchTerm, statusFilter, suppliers])
const filterSuppliers = () => {
let filtered = [...suppliers]
// Filter by search term
if (searchTerm) {
const term = searchTerm.toLowerCase()
filtered = filtered.filter(supplier =>
supplier.nom.toLowerCase().includes(term) ||
(supplier.contact && supplier.contact.toLowerCase().includes(term)) ||
(supplier.email && supplier.email.toLowerCase().includes(term)) ||
(supplier.telephone && supplier.telephone.includes(term)) ||
(supplier.ville && supplier.ville.toLowerCase().includes(term)) ||
(supplier.codePostal && supplier.codePostal.includes(term))
)
}
// Filter by status
if (statusFilter === 'active') {
filtered = filtered.filter(s => s.actif)
} else if (statusFilter === 'inactive') {
filtered = filtered.filter(s => !s.actif)
}
setFilteredSuppliers(filtered)
}
const fetchSuppliers = async () => {
try {
const response = await fetch('/api/fournisseurs')
if (response.ok) {
const data = await response.json()
setSuppliers(data)
setFilteredSuppliers(data)
}
} catch (error) {
console.error('Error fetching suppliers:', error)
toast.error('Erreur lors du chargement des fournisseurs')
} finally {
setLoading(false)
}
}
const handleSupplierSaved = async () => {
setIsAddDialogOpen(false)
setEditingSupplier(null)
await fetchSuppliers()
toast.success(editingSupplier ? 'Fournisseur mis à jour' : 'Fournisseur créé')
}
const handleViewSupplier = (supplier: Supplier) => {
setSelectedSupplier(supplier)
setIsDetailDialogOpen(true)
}
const handleEditSupplier = (supplier: Supplier) => {
setEditingSupplier(supplier)
setIsAddDialogOpen(true)
}
const handleToggleStatus = async (supplier: Supplier) => {
try {
const response = await fetch(`/api/fournisseurs/${supplier.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...supplier,
actif: !supplier.actif,
}),
})
if (response.ok) {
await fetchSuppliers()
toast.success(supplier.actif ? 'Fournisseur désactivé' : 'Fournisseur activé')
} else {
const error = await response.json()
toast.error(error.error || 'Erreur lors de la mise à jour')
}
} catch (error) {
console.error('Error toggling supplier status:', error)
toast.error('Erreur lors de la mise à jour du statut')
}
}
const handleDeleteSupplier = async (supplier: Supplier) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le fournisseur "${supplier.nom}" ?`)) {
return
}
try {
const response = await fetch(`/api/fournisseurs/${supplier.id}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchSuppliers()
toast.success('Fournisseur supprimé')
} else {
const error = await response.json()
toast.error(error.error || 'Erreur lors de la suppression')
}
} catch (error) {
console.error('Error deleting supplier:', error)
toast.error('Erreur lors de la suppression')
}
}
const activeCount = suppliers.filter(s => s.actif).length
const inactiveCount = suppliers.length - activeCount
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 Fournisseurs</h2>
<p className="text-sm text-gray-500">
{suppliers.length} fournisseur{suppliers.length !== 1 ? 's' : ''} enregistré{suppliers.length !== 1 ? 's' : ''}
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Nouveau Fournisseur
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingSupplier ? 'Modifier le Fournisseur' : 'Nouveau Fournisseur'}
</DialogTitle>
<DialogDescription>
{editingSupplier
? 'Modifiez les informations du fournisseur'
: 'Remplissez les informations pour créer un nouveau fournisseur'}
</DialogDescription>
</DialogHeader>
<SupplierForm
supplier={editingSupplier}
onSave={handleSupplierSaved}
onCancel={() => {
setIsAddDialogOpen(false)
setEditingSupplier(null)
}}
/>
</DialogContent>
</Dialog>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total</p>
<p className="text-2xl font-bold">{suppliers.length}</p>
</div>
<Building2 className="h-8 w-8 text-gray-400" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Actifs</p>
<p className="text-2xl font-bold text-green-600">{activeCount}</p>
</div>
<Power className="h-8 w-8 text-green-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Inactifs</p>
<p className="text-2xl font-bold text-gray-400">{inactiveCount}</p>
</div>
<PowerOff className="h-8 w-8 text-gray-400" />
</div>
</CardContent>
</Card>
</div>
{/* Search and Filter */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<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, contact, email, téléphone, ville..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-2">
<Button
variant={statusFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('all')}
>
Tous ({suppliers.length})
</Button>
<Button
variant={statusFilter === 'active' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('active')}
>
Actifs ({activeCount})
</Button>
<Button
variant={statusFilter === 'inactive' ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter('inactive')}
>
Inactifs ({inactiveCount})
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Suppliers Table */}
<Card>
<CardContent className="p-0">
<ScrollArea className="h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Fournisseur</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Adresse</TableHead>
<TableHead>Produits</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
Chargement...
</TableCell>
</TableRow>
) : filteredSuppliers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
<div className="flex flex-col items-center gap-2">
<Building2 className="h-12 w-12 text-gray-400" />
<p className="text-gray-500">
{searchTerm || statusFilter !== 'all'
? 'Aucun fournisseur trouvé'
: 'Aucun fournisseur enregistré'}
</p>
</div>
</TableCell>
</TableRow>
) : (
filteredSuppliers.map((supplier) => (
<TableRow key={supplier.id} className="hover:bg-gray-50">
<TableCell>
<div>
<div className="font-medium">{supplier.nom}</div>
{supplier.contact && (
<div className="text-sm text-gray-500">{supplier.contact}</div>
)}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
{supplier.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]">{supplier.email}</span>
</div>
)}
{supplier.telephone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="h-3 w-3 text-gray-400" />
<span>{supplier.telephone}</span>
</div>
)}
</div>
</TableCell>
<TableCell>
{supplier.adresse || supplier.ville || supplier.codePostal ? (
<div className="space-y-1">
{supplier.adresse && (
<div className="text-sm text-gray-600 truncate max-w-[150px]">
{supplier.adresse}
</div>
)}
<div className="flex items-center gap-1 text-sm text-gray-500">
<MapPin className="h-3 w-3" />
<span>
{supplier.codePostal && `${supplier.codePostal} `}
{supplier.ville}
</span>
</div>
</div>
) : (
<span className="text-gray-400 text-sm">Non renseignée</span>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">
{supplier._count?.produits || 0}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={supplier.actif ? 'default' : 'secondary'}
className={
supplier.actif
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}
>
{supplier.actif ? 'Actif' : 'Inactif'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewSupplier(supplier)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditSupplier(supplier)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleStatus(supplier)}
title={supplier.actif ? 'Désactiver' : 'Activer'}
>
{supplier.actif ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSupplier(supplier)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
{/* Supplier Detail Dialog */}
<Dialog open={isDetailDialogOpen} onOpenChange={setIsDetailDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Détail du Fournisseur</DialogTitle>
<DialogDescription>
Informations complètes et historique des achats
</DialogDescription>
</DialogHeader>
{selectedSupplier && (
<SupplierDetail
supplier={selectedSupplier}
onClose={() => setIsDetailDialogOpen(false)}
onUpdate={fetchSuppliers}
onEdit={() => {
setEditingSupplier(selectedSupplier)
setIsDetailDialogOpen(false)
setIsAddDialogOpen(true)
}}
/>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

353
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

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