Compare commits
11 Commits
a1ee2c0ae6
...
codex/demo
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2501721a | |||
| 865c4a78ea | |||
| 86beb8a5dd | |||
| e794ecceb6 | |||
| 14de88945c | |||
| b169e975bb | |||
| 661d053ea0 | |||
| 816c1c40c6 | |||
| d23f2ab53e | |||
| f6dc5fb07f | |||
| a8c372177f |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
test
|
||||||
|
*.log
|
||||||
|
local-*
|
||||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
local-*
|
||||||
|
.claude
|
||||||
|
.z-ai-config
|
||||||
|
dev.log
|
||||||
|
test
|
||||||
|
prompt
|
||||||
|
|
||||||
|
server.log
|
||||||
|
# Skills directory
|
||||||
|
/skills/
|
||||||
|
cookies.txt
|
||||||
|
*.log
|
||||||
117
.zscripts/build.sh
Normal file
117
.zscripts/build.sh
Normal 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"
|
||||||
78
.zscripts/mini-services-build.sh
Normal file
78
.zscripts/mini-services-build.sh
Normal 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
|
||||||
|
|
||||||
65
.zscripts/mini-services-install.sh
Normal file
65
.zscripts/mini-services-install.sh
Normal 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
|
||||||
|
|
||||||
123
.zscripts/mini-services-start.sh
Normal file
123
.zscripts/mini-services-start.sh
Normal 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
126
.zscripts/start.sh
Normal 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
2396
CAHIER_DE_TEST.md
Normal file
File diff suppressed because it is too large
Load Diff
23
Caddyfile
Normal file
23
Caddyfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
:81 {
|
||||||
|
@transform_port_query {
|
||||||
|
query XTransformPort=*
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @transform_port_query {
|
||||||
|
reverse_proxy localhost:{query.XTransformPort} {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy localhost:3000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# OptiqueStock
|
||||||
|
|
||||||
|
Application de gestion de magasin d'optique — full-stack Next.js avec gestion des clients, produits, ventes, achats, atelier et rapports.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Accueil** | Dashboard avec accès aux modules |
|
||||||
|
| **Clients** | Gestion des clients, relevés de vision, ordonnances |
|
||||||
|
| **Produits** | Catalogue (montures, verres, lentilles, accessoires), alertes stock, codes QR, images |
|
||||||
|
| **Fournisseurs** | Gestion des fournisseurs |
|
||||||
|
| **Achats & Stock** | Factures d'achat, réception de stock, upload PDF |
|
||||||
|
| **Point de Vente** | Panier, paiements fractionnés, historique des ventes |
|
||||||
|
| **Atelier** | Suivi des commandes de montage (4 statuts : EN_ATTENTE → EN_COURS → TERMINE → PRET) |
|
||||||
|
| **Rapports** | KPIs, graphiques (ventes, catégories, stocks), export CSV |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 16 (App Router), React 19, TypeScript 5
|
||||||
|
- **Base de données**: SQLite via Prisma ORM
|
||||||
|
- **UI**: Tailwind CSS 4, shadcn/ui, lucide-react
|
||||||
|
- **État & données**: Zustand, TanStack Query, TanStack Table
|
||||||
|
- **Formulaires**: react-hook-form + Zod
|
||||||
|
- **Graphiques**: Recharts
|
||||||
|
- **Autre**: Framer Motion, DND Kit, Sharp, QR Code, Sonner (toasts)
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cloner et configurer
|
||||||
|
bun install
|
||||||
|
echo 'DATABASE_URL="file:./dev.db"' > .env
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
bunx prisma generate
|
||||||
|
bunx prisma db push
|
||||||
|
|
||||||
|
# Lancer le serveur de développement
|
||||||
|
bun run dev
|
||||||
|
# → http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Commande | Description |
|
||||||
|
|---|---|
|
||||||
|
| `bun run dev` | Serveur de développement (port 3000) |
|
||||||
|
| `bun run build` | Build production |
|
||||||
|
| `bun start` | Serveur production |
|
||||||
|
| `bun run lint` | ESLint |
|
||||||
|
| `bun run db:push` | Push Prisma schema → DB |
|
||||||
|
| `bun run db:generate` | Générer client Prisma |
|
||||||
|
| `bun run db:migrate` | Migration Prisma |
|
||||||
|
| `bun run db:reset` | Reset base de données |
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # SPA — commutation de modules
|
||||||
|
│ ├── layout.tsx # Layout racine
|
||||||
|
│ ├── globals.css # Styles Tailwind + shadcn
|
||||||
|
│ └── api/ # API REST (12 groupes de routes)
|
||||||
|
├── components/
|
||||||
|
│ ├── clients/ # Module Clients
|
||||||
|
│ ├── products/ # Module Produits
|
||||||
|
│ ├── pos/ # Module Point de Vente
|
||||||
|
│ ├── purchases/ # Module Achats
|
||||||
|
│ ├── suppliers/ # Module Fournisseurs
|
||||||
|
│ ├── atelier/ # Module Atelier
|
||||||
|
│ ├── reports/ # Module Rapports
|
||||||
|
│ └── ui/ # Composants shadcn/ui
|
||||||
|
├── hooks/ # Hooks personnalisés
|
||||||
|
└── lib/ # Utilitaires (db, optical-utils, qr-code)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Toutes les routes sous `/api/` suivent le pattern REST :
|
||||||
|
|
||||||
|
- `api/clients`, `api/clients/[id]`, `api/clients/[id]/patients`
|
||||||
|
- `api/patients`, `api/patients/[id]`, `api/patients/[id]/ordonnances`
|
||||||
|
- `api/produits`, `api/produits/[id]`, `api/produits/[id]/images`
|
||||||
|
- `api/fournisseurs`, `api/fournisseurs/[id]`, `api/fournisseurs/[id]/factures`
|
||||||
|
- `api/achats/factures`, `api/achats/factures/[id]`, `api/achats/factures/[id]/valider`
|
||||||
|
- `api/pos/products`, `api/pos/clients`, `api/pos/sales`, `api/pos/sales/[id]`
|
||||||
|
- `api/atelier/orders`, `api/atelier/orders/[id]`
|
||||||
|
- `api/reports/dashboard`, `api/reports/sales`, `api/reports/inventory`, `api/reports/export/*`
|
||||||
|
- `api/fichiers/[id]`
|
||||||
|
|
||||||
|
Base de données SQLite auto-contenue — aucun serveur externe requis.
|
||||||
91
agent-ctx/3-c-full-stack-developer.md
Normal file
91
agent-ctx/3-c-full-stack-developer.md
Normal 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
|
||||||
189
agent-ctx/3-f-full-stack-developer.md
Normal file
189
agent-ctx/3-f-full-stack-developer.md
Normal 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
|
||||||
43
check-patients.js
Normal file
43
check-patients.js
Normal 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
21
components.json
Normal 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
BIN
db/custom.db
Normal file
Binary file not shown.
1
download/README.md
Normal file
1
download/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Here are all the generated files.
|
||||||
50
eslint.config.mjs
Normal file
50
eslint.config.mjs
Normal 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;
|
||||||
196
examples/websocket/frontend.tsx
Normal file
196
examples/websocket/frontend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
examples/websocket/server.ts
Normal file
138
examples/websocket/server.ts
Normal 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
0
mini-services/.gitkeep
Normal file
50
mini-services/hermes-mcp/README.md
Normal file
50
mini-services/hermes-mcp/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# OptiqueStock Hermes MCP
|
||||||
|
|
||||||
|
This MCP server gives a Hermes agent safe, token-scoped access to OptiqueStock.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Set the same key on the Next.js app and the MCP server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HERMES_API_KEY=change-me
|
||||||
|
OPTICZ_API_BASE=http://192.168.1.30:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
In local development only, the fallback key is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hermes-demo-key-change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node mini-services/hermes-mcp/server.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
- `opticz_status`
|
||||||
|
- `opticz_summary`
|
||||||
|
- `opticz_search_clients`
|
||||||
|
- `opticz_search_products`
|
||||||
|
- `opticz_create_client`
|
||||||
|
- `opticz_create_repair_sale`
|
||||||
|
|
||||||
|
## Direct REST API
|
||||||
|
|
||||||
|
All endpoints require:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <HERMES_API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
Available endpoints:
|
||||||
|
|
||||||
|
- `GET /api/hermes/status`
|
||||||
|
- `GET /api/hermes/summary`
|
||||||
|
- `GET /api/hermes/clients?q=amina`
|
||||||
|
- `POST /api/hermes/clients`
|
||||||
|
- `GET /api/hermes/products?q=monture`
|
||||||
|
- `POST /api/hermes/sales/repair`
|
||||||
242
mini-services/hermes-mcp/server.mjs
Normal file
242
mini-services/hermes-mcp/server.mjs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const apiBase = (process.env.OPTICZ_API_BASE || 'http://127.0.0.1:3000').replace(/\/$/, '')
|
||||||
|
const apiKey = process.env.HERMES_API_KEY || 'hermes-demo-key-change-me'
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: 'opticz_status',
|
||||||
|
description: 'Check OptiqueStock Hermes API health.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'opticz_summary',
|
||||||
|
description: 'Get store counts, low stock products, pending workshop count, and recent sales.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'opticz_search_clients',
|
||||||
|
description: 'Search OptiqueStock clients by name, phone, or email.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
q: { type: 'string', description: 'Search query.' },
|
||||||
|
limit: { type: 'number', description: 'Maximum results, default 20, max 50.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'opticz_search_products',
|
||||||
|
description: 'Search active products by reference, designation, brand, or category.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
q: { type: 'string', description: 'Search query.' },
|
||||||
|
category: { type: 'string', description: 'Optional category filter, e.g. MONTURE or VERRE.' },
|
||||||
|
inStockOnly: { type: 'boolean', description: 'Only include products with stock. Defaults true.' },
|
||||||
|
limit: { type: 'number', description: 'Maximum results, default 25, max 75.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'opticz_create_client',
|
||||||
|
description: 'Create a client, or return the existing client when the phone number already exists.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['nom', 'prenom', 'telephone'],
|
||||||
|
properties: {
|
||||||
|
nom: { type: 'string' },
|
||||||
|
prenom: { type: 'string' },
|
||||||
|
telephone: { type: 'string' },
|
||||||
|
email: { type: 'string' },
|
||||||
|
adresse: { type: 'string' },
|
||||||
|
ville: { type: 'string' },
|
||||||
|
codePostal: { type: 'string' },
|
||||||
|
notes: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'opticz_create_repair_sale',
|
||||||
|
description: 'Create a paid repair sale for an existing client.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['clientId', 'repairType', 'repairDescription', 'repairPrice'],
|
||||||
|
properties: {
|
||||||
|
clientId: { type: 'string' },
|
||||||
|
repairType: { type: 'string' },
|
||||||
|
repairDescription: { type: 'string' },
|
||||||
|
repairPrice: { type: 'number' },
|
||||||
|
additionalCharges: { type: 'number' },
|
||||||
|
paymentMode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['ESPECES', 'CARTE', 'CHEQUE', 'VIREMENT', 'BON_CAISSE'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(`${apiBase}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
const data = text ? JSON.parse(text) : null
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || `HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(params) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params || {})) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
search.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const value = search.toString()
|
||||||
|
return value ? `?${value}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callTool(name, args) {
|
||||||
|
switch (name) {
|
||||||
|
case 'opticz_status':
|
||||||
|
return api('/api/hermes/status')
|
||||||
|
case 'opticz_summary':
|
||||||
|
return api('/api/hermes/summary')
|
||||||
|
case 'opticz_search_clients':
|
||||||
|
return api(`/api/hermes/clients${query(args)}`)
|
||||||
|
case 'opticz_search_products':
|
||||||
|
return api(`/api/hermes/products${query(args)}`)
|
||||||
|
case 'opticz_create_client':
|
||||||
|
return api('/api/hermes/clients', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(args || {}),
|
||||||
|
})
|
||||||
|
case 'opticz_create_repair_sale':
|
||||||
|
return api('/api/hermes/sales/repair', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(args || {}),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tool: ${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message) {
|
||||||
|
process.stdout.write(`${JSON.stringify(message)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(message) {
|
||||||
|
const { id, method, params } = message
|
||||||
|
|
||||||
|
if (method === 'initialize') {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: params?.protocolVersion || '2024-11-05',
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
serverInfo: {
|
||||||
|
name: 'opticz-hermes-mcp',
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'notifications/initialized') return
|
||||||
|
|
||||||
|
if (method === 'tools/list') {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: { tools },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'tools/call') {
|
||||||
|
try {
|
||||||
|
const result = await callTool(params?.name, params?.arguments || {})
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
code: -32601,
|
||||||
|
message: `Unsupported method: ${method}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
process.stdin.setEncoding('utf8')
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
buffer += chunk
|
||||||
|
const lines = buffer.split(/\r?\n/)
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
handle(JSON.parse(line))
|
||||||
|
} catch (error) {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: null,
|
||||||
|
error: {
|
||||||
|
code: -32700,
|
||||||
|
message: error instanceof Error ? error.message : 'Parse error',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
13
next.config.ts
Normal file
13
next.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
allowedDevOrigins: ["192.168.1.30:3000"],
|
||||||
|
/* config options here */
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
reactStrictMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
12480
package-lock.json
generated
Normal file
12480
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
package.json
Normal file
99
package.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"name": "nextjs_tailwind_shadcn_ts",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
||||||
|
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||||
|
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:reset": "prisma migrate reset",
|
||||||
|
"hermes:mcp": "node mini-services/hermes-mcp/server.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@mdxeditor/editor": "^3.39.1",
|
||||||
|
"@prisma/client": "^6.11.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.15",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@reactuses/core": "^6.0.5",
|
||||||
|
"@tanstack/react-query": "^5.82.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.23.2",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
|
"next": "^16.1.1",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
|
"next-intl": "^4.3.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"prisma": "^6.11.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.60.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"z-ai-web-dev-sdk": "^0.0.16",
|
||||||
|
"zod": "^4.0.2",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"bun-types": "^1.3.4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.1.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.3.5",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
348
prisma/schema.prisma
Normal file
348
prisma/schema.prisma
Normal 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
29
public/logo.svg
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
|
||||||
|
.st23{fill:#FFFFFF;}
|
||||||
|
|
||||||
|
.z-breathe {
|
||||||
|
animation: breathe 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g>
|
||||||
|
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
|
||||||
|
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
|
||||||
|
<g class="z-breathe">
|
||||||
|
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
|
||||||
|
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1"/>
|
||||||
|
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
19
public/manifest.json
Normal file
19
public/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "OptiqueStock",
|
||||||
|
"short_name": "OptiqueStock",
|
||||||
|
"description": "Gestion de magasin d'optique",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#020617",
|
||||||
|
"theme_color": "#020617",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/logo.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
public/robots.txt
Normal file
14
public/robots.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
BIN
public/uploads/products/82caa774-b63e-4550-a52a-7a8e19ef4217.png
Normal file
BIN
public/uploads/products/82caa774-b63e-4550-a52a-7a8e19ef4217.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
public/uploads/products/f024f315-919a-47f0-93e1-f7e989c61fad.jpg
Normal file
BIN
public/uploads/products/f024f315-919a-47f0-93e1-f7e989c61fad.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
80
src/app/api/achats/factures/[id]/route.ts
Normal file
80
src/app/api/achats/factures/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/app/api/achats/factures/[id]/valider/route.ts
Normal file
70
src/app/api/achats/factures/[id]/valider/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/app/api/achats/factures/route.ts
Normal file
123
src/app/api/achats/factures/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/app/api/achats/upload-facture/route.ts
Normal file
82
src/app/api/achats/upload-facture/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/app/api/ai/assistant/route.ts
Normal file
82
src/app/api/ai/assistant/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import ZAI from 'z-ai-web-dev-sdk'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireAuth } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAuth()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const prompt = String(body.prompt || '').trim()
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return NextResponse.json({ error: 'Votre demande est vide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [clients, produits, ventes, lowStock, pendingWorkshop] = await Promise.all([
|
||||||
|
db.client.count(),
|
||||||
|
db.produit.count({ where: { actif: true } }),
|
||||||
|
db.vente.count(),
|
||||||
|
db.produit.findMany({
|
||||||
|
where: {
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
stock: 'asc',
|
||||||
|
},
|
||||||
|
take: 8,
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
designation: true,
|
||||||
|
stock: true,
|
||||||
|
stockMin: true,
|
||||||
|
categorie: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.vente.count({
|
||||||
|
where: {
|
||||||
|
statutAtelier: {
|
||||||
|
in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const storeContext = {
|
||||||
|
clients,
|
||||||
|
produitsActifs: produits,
|
||||||
|
ventes,
|
||||||
|
commandesAtelierEnCours: pendingWorkshop,
|
||||||
|
produitsARevoir: lowStock.filter((item) => item.stock <= item.stockMin),
|
||||||
|
}
|
||||||
|
|
||||||
|
const zai = await ZAI.create()
|
||||||
|
const response = await zai.chat.completions.create({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Tu es un assistant IA pour OptiqueStock, un logiciel de gestion de magasin d optique. Reponds en francais, avec des conseils pratiques, courts et directement exploitables. Ne promets pas d actions automatiques.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Contexte du magasin: ${JSON.stringify(storeContext)}\n\nDemande: ${prompt}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
thinking: { type: 'disabled' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
answer: response.choices[0]?.message?.content || 'Aucune reponse generee.',
|
||||||
|
context: storeContext,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI assistant error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Erreur IA inconnue' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/api/analyze-images/route.ts
Normal file
54
src/app/api/analyze-images/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/app/api/atelier/orders/[id]/route.ts
Normal file
155
src/app/api/atelier/orders/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/api/atelier/orders/route.ts
Normal file
86
src/app/api/atelier/orders/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/app/api/atelier/seed/route.ts
Normal file
172
src/app/api/atelier/seed/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions)
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
36
src/app/api/auth/seed/route.ts
Normal file
36
src/app/api/auth/seed/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const existing = await db.employe.findUnique({
|
||||||
|
where: { email: 'admin@optiquestock.com' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ message: 'Admin already exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash('admin123', 12)
|
||||||
|
|
||||||
|
const employe = await db.employe.create({
|
||||||
|
data: {
|
||||||
|
email: 'admin@optiquestock.com',
|
||||||
|
nom: 'Admin',
|
||||||
|
prenom: 'Admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
motDePasse: hashedPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Admin user created',
|
||||||
|
email: employe.email,
|
||||||
|
password: 'admin123',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Seed error:', error)
|
||||||
|
return NextResponse.json({ error: 'Seed failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/clients/[id]/patients/route.ts
Normal file
43
src/app/api/clients/[id]/patients/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/app/api/clients/[id]/route.ts
Normal file
146
src/app/api/clients/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/app/api/clients/route.ts
Normal file
89
src/app/api/clients/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
272
src/app/api/demo/seed/route.ts
Normal file
272
src/app/api/demo/seed/route.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { ModePaiement, StatutAtelier, StatutVente } from '@prisma/client'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifie' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((session.user as any).role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function htFromTtc(ttc: number, tva = 20) {
|
||||||
|
return ttc / (1 + tva / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suppliers = await Promise.all([
|
||||||
|
db.fournisseur.upsert({
|
||||||
|
where: { id: 'demo-fournisseur-lumioptic' },
|
||||||
|
update: {
|
||||||
|
nom: 'LumiOptic Distribution',
|
||||||
|
contact: 'Nadia Benali',
|
||||||
|
email: 'contact@demo-lumioptic.local',
|
||||||
|
telephone: '0522000001',
|
||||||
|
ville: 'Casablanca',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: 'demo-fournisseur-lumioptic',
|
||||||
|
nom: 'LumiOptic Distribution',
|
||||||
|
contact: 'Nadia Benali',
|
||||||
|
email: 'contact@demo-lumioptic.local',
|
||||||
|
telephone: '0522000001',
|
||||||
|
ville: 'Casablanca',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.fournisseur.upsert({
|
||||||
|
where: { id: 'demo-fournisseur-clairverre' },
|
||||||
|
update: {
|
||||||
|
nom: 'ClairVerre Pro',
|
||||||
|
contact: 'Yassine El Amrani',
|
||||||
|
email: 'contact@demo-clairverre.local',
|
||||||
|
telephone: '0522000002',
|
||||||
|
ville: 'Rabat',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: 'demo-fournisseur-clairverre',
|
||||||
|
nom: 'ClairVerre Pro',
|
||||||
|
contact: 'Yassine El Amrani',
|
||||||
|
email: 'contact@demo-clairverre.local',
|
||||||
|
telephone: '0522000002',
|
||||||
|
ville: 'Rabat',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const productsData = [
|
||||||
|
['DEMO-MNT-001', 'Monture acetate noir Atlas', 'MONTURE', 42, 129, 12, 4, 'Atlas', 'COMPLET', suppliers[0].id],
|
||||||
|
['DEMO-MNT-002', 'Monture metal fine Sofia', 'MONTURE', 38, 115, 9, 3, 'Sofia', 'COMPLET', suppliers[0].id],
|
||||||
|
['DEMO-MNT-003', 'Monture enfant Flex Kids', 'MONTURE', 25, 79, 14, 5, 'Flex Kids', 'NATUREL', suppliers[0].id],
|
||||||
|
['DEMO-VR-001', 'Verres simple vision anti-reflet', 'VERRE', 18, 59, 40, 10, null, null, suppliers[1].id],
|
||||||
|
['DEMO-VR-002', 'Verres progressifs confort', 'VERRE', 58, 189, 18, 6, null, null, suppliers[1].id],
|
||||||
|
['DEMO-VR-003', 'Verres anti-lumiere bleue', 'VERRE', 24, 89, 22, 8, null, null, suppliers[1].id],
|
||||||
|
['DEMO-LEN-001', 'Lentilles mensuelles confort', 'LENTILLE', 9, 29, 30, 8, 'ClearDay', null, suppliers[1].id],
|
||||||
|
['DEMO-ACC-001', 'Kit nettoyage premium', 'ACCESSOIRE', 3, 12, 50, 12, null, null, suppliers[0].id],
|
||||||
|
['DEMO-ACC-002', 'Etui rigide signature', 'ACCESSOIRE', 5, 18, 35, 10, null, null, suppliers[0].id],
|
||||||
|
['DEMO-ACC-003', 'Cordon lunettes sport', 'ACCESSOIRE', 2, 9, 5, 10, null, null, suppliers[0].id],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const products = await Promise.all(
|
||||||
|
productsData.map(([reference, designation, categorie, prixAchatHT, prixVenteTTC, stock, stockMin, marque, typeMonture, fournisseurId]) =>
|
||||||
|
db.produit.upsert({
|
||||||
|
where: { reference },
|
||||||
|
update: {
|
||||||
|
designation,
|
||||||
|
categorie,
|
||||||
|
prixAchatHT,
|
||||||
|
prixVenteTTC,
|
||||||
|
stock,
|
||||||
|
stockMin,
|
||||||
|
marque,
|
||||||
|
typeMonture: typeMonture as any,
|
||||||
|
fournisseurId,
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
reference,
|
||||||
|
designation,
|
||||||
|
categorie,
|
||||||
|
prixAchatHT,
|
||||||
|
prixVenteTTC,
|
||||||
|
tva: 20,
|
||||||
|
stock,
|
||||||
|
stockMin,
|
||||||
|
marque,
|
||||||
|
typeMonture: typeMonture as any,
|
||||||
|
fournisseurId,
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const clientsData = [
|
||||||
|
['demo-client-1', 'Amina', 'Rachid', '0600000101', 'amina.rachid@demo.local', 'Casablanca'],
|
||||||
|
['demo-client-2', 'Karim', 'Bennani', '0600000102', 'karim.bennani@demo.local', 'Rabat'],
|
||||||
|
['demo-client-3', 'Sara', 'El Fassi', '0600000103', 'sara.elfassi@demo.local', 'Marrakech'],
|
||||||
|
['demo-client-4', 'Youssef', 'Idrissi', '0600000104', null, 'Tanger'],
|
||||||
|
['demo-client-5', 'Leila', 'Mansouri', '0600000105', 'leila.mansouri@demo.local', 'Fes'],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const clients = await Promise.all(
|
||||||
|
clientsData.map(([id, prenom, nom, telephone, email, ville]) =>
|
||||||
|
db.client.upsert({
|
||||||
|
where: { telephone },
|
||||||
|
update: {
|
||||||
|
prenom,
|
||||||
|
nom,
|
||||||
|
email,
|
||||||
|
ville,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
prenom,
|
||||||
|
nom,
|
||||||
|
telephone,
|
||||||
|
email,
|
||||||
|
ville,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const password = await bcrypt.hash('demo123', 12)
|
||||||
|
const employees = await Promise.all([
|
||||||
|
db.employe.upsert({
|
||||||
|
where: { email: 'vendeur.demo@optiquestock.local' },
|
||||||
|
update: {
|
||||||
|
nom: 'Demo',
|
||||||
|
prenom: 'Vendeur',
|
||||||
|
role: 'VENDEUR',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: 'vendeur.demo@optiquestock.local',
|
||||||
|
nom: 'Demo',
|
||||||
|
prenom: 'Vendeur',
|
||||||
|
role: 'VENDEUR',
|
||||||
|
actif: true,
|
||||||
|
motDePasse: password,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.employe.upsert({
|
||||||
|
where: { email: 'responsable.demo@optiquestock.local' },
|
||||||
|
update: {
|
||||||
|
nom: 'Demo',
|
||||||
|
prenom: 'Responsable',
|
||||||
|
role: 'RESPONSABLE',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: 'responsable.demo@optiquestock.local',
|
||||||
|
nom: 'Demo',
|
||||||
|
prenom: 'Responsable',
|
||||||
|
role: 'RESPONSABLE',
|
||||||
|
actif: true,
|
||||||
|
motDePasse: password,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const saleSpecs = [
|
||||||
|
{
|
||||||
|
numero: 'DEMO-VENTE-001',
|
||||||
|
client: clients[0],
|
||||||
|
statutAtelier: StatutAtelier.EN_COURS,
|
||||||
|
items: [products[0], products[3], products[7]],
|
||||||
|
payment: ModePaiement.CARTE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numero: 'DEMO-VENTE-002',
|
||||||
|
client: clients[1],
|
||||||
|
statutAtelier: StatutAtelier.PRET,
|
||||||
|
items: [products[1], products[4]],
|
||||||
|
payment: ModePaiement.ESPECES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numero: 'DEMO-VENTE-003',
|
||||||
|
client: clients[2],
|
||||||
|
statutAtelier: StatutAtelier.EN_ATTENTE,
|
||||||
|
items: [products[2], products[5]],
|
||||||
|
payment: ModePaiement.CHEQUE,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let salesCreated = 0
|
||||||
|
for (const spec of saleSpecs) {
|
||||||
|
const existingSale = await db.vente.findUnique({ where: { numero: spec.numero } })
|
||||||
|
if (existingSale) continue
|
||||||
|
|
||||||
|
const montantTTC = spec.items.reduce((sum, product) => sum + product.prixVenteTTC, 0)
|
||||||
|
const montantHT = spec.items.reduce((sum, product) => sum + htFromTtc(product.prixVenteTTC), 0)
|
||||||
|
|
||||||
|
await db.vente.create({
|
||||||
|
data: {
|
||||||
|
numero: spec.numero,
|
||||||
|
clientId: spec.client.id,
|
||||||
|
employeId: employees[0].id,
|
||||||
|
statut: StatutVente.PAYEE,
|
||||||
|
statutAtelier: spec.statutAtelier,
|
||||||
|
montantHT,
|
||||||
|
montantTVA: montantTTC - montantHT,
|
||||||
|
montantTTC,
|
||||||
|
notes: 'Vente demo generee automatiquement',
|
||||||
|
dateAtelier: new Date(),
|
||||||
|
lignes: {
|
||||||
|
create: spec.items.map((product) => ({
|
||||||
|
produitId: product.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT: htFromTtc(product.prixVenteTTC),
|
||||||
|
prixUnitaireTTC: product.prixVenteTTC,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: htFromTtc(product.prixVenteTTC),
|
||||||
|
montantTTC: product.prixVenteTTC,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
paiements: {
|
||||||
|
create: {
|
||||||
|
mode: spec.payment,
|
||||||
|
montant: montantTTC,
|
||||||
|
employeId: employees[0].id,
|
||||||
|
reference: spec.numero,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
salesCreated += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Demo data ready',
|
||||||
|
clients: clients.length,
|
||||||
|
products: products.length,
|
||||||
|
suppliers: suppliers.length,
|
||||||
|
employees: employees.length,
|
||||||
|
salesCreated,
|
||||||
|
demoLogins: [
|
||||||
|
{ email: 'vendeur.demo@optiquestock.local', password: 'demo123' },
|
||||||
|
{ email: 'responsable.demo@optiquestock.local', password: 'demo123' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Demo seed error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to seed demo data' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/app/api/employes/[id]/route.ts
Normal file
169
src/app/api/employes/[id]/route.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const allowedRoles = ['VENDEUR', 'RESPONSABLE', 'ADMIN'] as const
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifie' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((session.user as any).role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLastActiveAdmin(id: string) {
|
||||||
|
const employe = await db.employe.findUnique({ where: { id } })
|
||||||
|
if (!employe || employe.role !== 'ADMIN' || !employe.actif) return false
|
||||||
|
|
||||||
|
const activeAdmins = await db.employe.count({
|
||||||
|
where: {
|
||||||
|
role: 'ADMIN',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return activeAdmins <= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const existing = await db.employe.findUnique({ where: { id } })
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Employe introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.role && !allowedRoles.includes(body.role)) {
|
||||||
|
return NextResponse.json({ error: 'Role invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removingAdminAccess =
|
||||||
|
existing.role === 'ADMIN' &&
|
||||||
|
existing.actif &&
|
||||||
|
((body.role && body.role !== 'ADMIN') || body.actif === false)
|
||||||
|
|
||||||
|
if (removingAdminAccess && await isLastActiveAdmin(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Impossible de retirer le dernier administrateur actif' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.email && body.email.toLowerCase() !== existing.email) {
|
||||||
|
const duplicate = await db.employe.findUnique({
|
||||||
|
where: { email: body.email.toLowerCase() },
|
||||||
|
})
|
||||||
|
if (duplicate) {
|
||||||
|
return NextResponse.json({ error: 'Un employe utilise deja cet email' }, { status: 409 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const employe = await db.employe.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
email: body.email !== undefined ? String(body.email).toLowerCase() : existing.email,
|
||||||
|
nom: body.nom !== undefined ? body.nom : existing.nom,
|
||||||
|
prenom: body.prenom !== undefined ? body.prenom : existing.prenom,
|
||||||
|
role: body.role !== undefined ? body.role : existing.role,
|
||||||
|
actif: body.actif !== undefined ? Boolean(body.actif) : existing.actif,
|
||||||
|
...(body.password ? { motDePasse: await bcrypt.hash(body.password, 12) } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(employe)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating employe:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to update employee' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const existing = await db.employe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
ventes: true,
|
||||||
|
facturesAchat: true,
|
||||||
|
paiements: true,
|
||||||
|
patients: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Employe introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isLastActiveAdmin(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Impossible de supprimer le dernier administrateur actif' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHistory =
|
||||||
|
existing._count.ventes > 0 ||
|
||||||
|
existing._count.facturesAchat > 0 ||
|
||||||
|
existing._count.paiements > 0 ||
|
||||||
|
existing._count.patients > 0
|
||||||
|
|
||||||
|
if (hasHistory) {
|
||||||
|
const employe = await db.employe.update({
|
||||||
|
where: { id },
|
||||||
|
data: { actif: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return NextResponse.json({ success: true, archived: true, employe })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.employe.delete({ where: { id } })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting employe:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete employee' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/app/api/employes/route.ts
Normal file
107
src/app/api/employes/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const allowedRoles = ['VENDEUR', 'RESPONSABLE', 'ADMIN'] as const
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Non authentifie' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((session.user as any).role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const employes = await db.employe.findMany({
|
||||||
|
orderBy: [{ actif: 'desc' }, { nom: 'asc' }, { prenom: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
ventes: true,
|
||||||
|
facturesAchat: true,
|
||||||
|
paiements: true,
|
||||||
|
patients: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(employes)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching employes:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch employees' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAdmin()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
if (!body.email || !body.nom || !body.prenom || !body.password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email, nom, prenom et mot de passe sont obligatoires' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(body.role)) {
|
||||||
|
return NextResponse.json({ error: 'Role invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.employe.findUnique({
|
||||||
|
where: { email: String(body.email).toLowerCase() },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: 'Un employe utilise deja cet email' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const employe = await db.employe.create({
|
||||||
|
data: {
|
||||||
|
email: String(body.email).toLowerCase(),
|
||||||
|
nom: body.nom,
|
||||||
|
prenom: body.prenom,
|
||||||
|
role: body.role,
|
||||||
|
actif: body.actif !== undefined ? Boolean(body.actif) : true,
|
||||||
|
motDePasse: await bcrypt.hash(body.password, 12),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
role: true,
|
||||||
|
actif: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(employe)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating employe:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to create employee' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/fichiers/[id]/route.ts
Normal file
47
src/app/api/fichiers/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/app/api/fournisseurs/[id]/factures/route.ts
Normal file
41
src/app/api/fournisseurs/[id]/factures/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/app/api/fournisseurs/[id]/route.ts
Normal file
149
src/app/api/fournisseurs/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/fournisseurs/route.ts
Normal file
76
src/app/api/fournisseurs/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/hermes/clients/route.ts
Normal file
76
src/app/api/hermes/clients/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = requireHermesAccess(request)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const q = (searchParams.get('q') || '').trim()
|
||||||
|
const limit = Math.min(Number(searchParams.get('limit') || 20), 50)
|
||||||
|
|
||||||
|
const clients = await db.client.findMany({
|
||||||
|
where: q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nom: { contains: q } },
|
||||||
|
{ prenom: { contains: q } },
|
||||||
|
{ telephone: { contains: q } },
|
||||||
|
{ email: { contains: q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: [{ nom: 'asc' }, { prenom: 'asc' }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
email: true,
|
||||||
|
telephone: true,
|
||||||
|
ville: true,
|
||||||
|
codePostal: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ clients })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = requireHermesAccess(request)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const nom = String(body.nom || '').trim()
|
||||||
|
const prenom = String(body.prenom || '').trim()
|
||||||
|
const telephone = String(body.telephone || '').trim()
|
||||||
|
|
||||||
|
if (!nom || !prenom || !telephone) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'nom, prenom and telephone are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingClient = await db.client.findUnique({ where: { telephone } })
|
||||||
|
if (existingClient) {
|
||||||
|
return NextResponse.json({ client: existingClient, existed: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await db.client.create({
|
||||||
|
data: {
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
telephone,
|
||||||
|
email: body.email || null,
|
||||||
|
adresse: body.adresse || null,
|
||||||
|
ville: body.ville || null,
|
||||||
|
codePostal: body.codePostal || null,
|
||||||
|
notes: body.notes || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ client, existed: false }, { status: 201 })
|
||||||
|
}
|
||||||
48
src/app/api/hermes/products/route.ts
Normal file
48
src/app/api/hermes/products/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = requireHermesAccess(request)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const q = (searchParams.get('q') || '').trim()
|
||||||
|
const category = (searchParams.get('category') || '').trim()
|
||||||
|
const inStockOnly = searchParams.get('inStockOnly') !== 'false'
|
||||||
|
const limit = Math.min(Number(searchParams.get('limit') || 25), 75)
|
||||||
|
|
||||||
|
const products = await db.produit.findMany({
|
||||||
|
where: {
|
||||||
|
actif: true,
|
||||||
|
...(inStockOnly ? { stock: { gt: 0 } } : {}),
|
||||||
|
...(category ? { categorie: category } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ reference: { contains: q } },
|
||||||
|
{ designation: { contains: q } },
|
||||||
|
{ marque: { contains: q } },
|
||||||
|
{ categorie: { contains: q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ categorie: 'asc' }, { designation: 'asc' }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reference: true,
|
||||||
|
designation: true,
|
||||||
|
categorie: true,
|
||||||
|
marque: true,
|
||||||
|
prixVenteTTC: true,
|
||||||
|
tva: true,
|
||||||
|
stock: true,
|
||||||
|
stockMin: true,
|
||||||
|
emplacement: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ products })
|
||||||
|
}
|
||||||
125
src/app/api/hermes/sales/repair/route.ts
Normal file
125
src/app/api/hermes/sales/repair/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { ModePaiement } from '@prisma/client'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||||
|
|
||||||
|
const SERVICE_TVA = 20
|
||||||
|
|
||||||
|
async function generateSaleNumber(): Promise<string> {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const salesThisMonth = await db.vente.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: new Date(year, today.getMonth(), 1),
|
||||||
|
lt: new Date(year, today.getMonth() + 1, 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return `H${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHt(ttc: number) {
|
||||||
|
return ttc / (1 + SERVICE_TVA / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRepairProduct() {
|
||||||
|
return db.produit.upsert({
|
||||||
|
where: { reference: 'HERMES_SERVICE_REPAIR' },
|
||||||
|
update: {
|
||||||
|
designation: 'Hermes service reparation',
|
||||||
|
actif: true,
|
||||||
|
stock: { increment: 1 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
reference: 'HERMES_SERVICE_REPAIR',
|
||||||
|
designation: 'Hermes service reparation',
|
||||||
|
categorie: 'SERVICE',
|
||||||
|
prixAchatHT: 0,
|
||||||
|
prixVenteTTC: 0,
|
||||||
|
tva: SERVICE_TVA,
|
||||||
|
stock: 999999,
|
||||||
|
stockMin: 0,
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = requireHermesAccess(request)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const clientId = String(body.clientId || '').trim()
|
||||||
|
const repairType = String(body.repairType || '').trim()
|
||||||
|
const repairDescription = String(body.repairDescription || '').trim()
|
||||||
|
const repairPrice = Math.max(0, Number(body.repairPrice || 0))
|
||||||
|
const additionalCharges = Math.max(0, Number(body.additionalCharges || 0))
|
||||||
|
const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement
|
||||||
|
|
||||||
|
if (!clientId || !repairType || !repairDescription) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'clientId, repairType and repairDescription are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await db.client.findUnique({ where: { id: clientId } })
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairProduct = await getRepairProduct()
|
||||||
|
const totalTTC = repairPrice + additionalCharges
|
||||||
|
const totalHT = toHt(totalTTC)
|
||||||
|
const numero = await generateSaleNumber()
|
||||||
|
|
||||||
|
const sale = await db.vente.create({
|
||||||
|
data: {
|
||||||
|
numero,
|
||||||
|
clientId,
|
||||||
|
statut: 'PAYEE',
|
||||||
|
montantHT: totalHT,
|
||||||
|
montantTVA: totalTTC - totalHT,
|
||||||
|
montantTTC: totalTTC,
|
||||||
|
notes: [
|
||||||
|
`Hermes repair: ${repairType}`,
|
||||||
|
`Description: ${repairDescription}`,
|
||||||
|
additionalCharges > 0 ? `Additional charges: ${additionalCharges.toFixed(2)}` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
lignes: {
|
||||||
|
create: {
|
||||||
|
produitId: repairProduct.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT: totalHT,
|
||||||
|
prixUnitaireTTC: totalTTC,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: totalHT,
|
||||||
|
montantTTC: totalTTC,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paiements: {
|
||||||
|
create: {
|
||||||
|
mode: paymentMode,
|
||||||
|
montant: totalTTC,
|
||||||
|
reference: 'HERMES',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paiements: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ sale }, { status: 201 })
|
||||||
|
}
|
||||||
16
src/app/api/hermes/status/route.ts
Normal file
16
src/app/api/hermes/status/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = requireHermesAccess(request)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
await db.$queryRaw`SELECT 1`
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
service: 'OptiqueStock Hermes API',
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
66
src/app/api/hermes/summary/route.ts
Normal file
66
src/app/api/hermes/summary/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { requireHermesAccess } from '@/lib/hermes-auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = requireHermesAccess(request)
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
const [clients, activeProducts, lowStock, pendingWorkshop, sales] = await Promise.all([
|
||||||
|
db.client.count(),
|
||||||
|
db.produit.count({ where: { actif: true } }),
|
||||||
|
db.produit.findMany({
|
||||||
|
where: { actif: true },
|
||||||
|
orderBy: { stock: 'asc' },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reference: true,
|
||||||
|
designation: true,
|
||||||
|
categorie: true,
|
||||||
|
stock: true,
|
||||||
|
stockMin: true,
|
||||||
|
prixVenteTTC: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.vente.count({
|
||||||
|
where: {
|
||||||
|
statutAtelier: {
|
||||||
|
in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.vente.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 8,
|
||||||
|
include: {
|
||||||
|
client: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nom: true,
|
||||||
|
prenom: true,
|
||||||
|
telephone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
counts: {
|
||||||
|
clients,
|
||||||
|
activeProducts,
|
||||||
|
pendingWorkshop,
|
||||||
|
},
|
||||||
|
lowStock: lowStock.filter((item) => item.stock <= item.stockMin),
|
||||||
|
recentSales: sales.map((sale) => ({
|
||||||
|
id: sale.id,
|
||||||
|
numero: sale.numero,
|
||||||
|
date: sale.date,
|
||||||
|
statut: sale.statut,
|
||||||
|
statutAtelier: sale.statutAtelier,
|
||||||
|
montantTTC: sale.montantTTC,
|
||||||
|
client: sale.client,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
123
src/app/api/ordonnances/[id]/route.ts
Normal file
123
src/app/api/ordonnances/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/app/api/ordonnances/route.ts
Normal file
101
src/app/api/ordonnances/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/patients/[id]/ordonnances/route.ts
Normal file
34
src/app/api/patients/[id]/ordonnances/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/api/patients/[id]/route.ts
Normal file
115
src/app/api/patients/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/patients/route.ts
Normal file
94
src/app/api/patients/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/pos/clients/route.ts
Normal file
67
src/app/api/pos/clients/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/pos/products/route.ts
Normal file
27
src/app/api/pos/products/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/app/api/pos/sales/[id]/route.ts
Normal file
165
src/app/api/pos/sales/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/app/api/pos/sales/route.ts
Normal file
199
src/app/api/pos/sales/route.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { StatutVente, ModePaiement } from '@prisma/client'
|
||||||
|
import { getCurrentUserId, requireAuth } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
// Helper function to generate sale number
|
||||||
|
async function generateSaleNumber(): Promise<string> {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
|
||||||
|
// Count sales for this month
|
||||||
|
const salesThisMonth = await db.vente.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: new Date(year, today.getMonth(), 1),
|
||||||
|
lt: new Date(year, today.getMonth() + 1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sequence = String(salesThisMonth + 1).padStart(4, '0')
|
||||||
|
return `V${year}${month}${sequence}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/pos/sales - Get all sales with relations
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sales = await db.vente.findMany({
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
employe: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paiements: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
take: 50 // Limit to last 50 sales
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(sales)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sales:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch sales' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/pos/sales - Create a new sale
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authError = await requireAuth()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
clientId,
|
||||||
|
lignes,
|
||||||
|
paiements,
|
||||||
|
remise,
|
||||||
|
montantHT,
|
||||||
|
montantTVA,
|
||||||
|
montantTTC,
|
||||||
|
notes
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!lignes || lignes.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Sale must have at least one line' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paiements || paiements.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Sale must have at least one payment' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify product availability
|
||||||
|
for (const ligne of lignes) {
|
||||||
|
const product = await db.produit.findUnique({
|
||||||
|
where: { id: ligne.produitId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Product ${ligne.produitId} not found` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.stock < ligne.quantite) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Insufficient stock for product ${product.designation}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const employeId = await getCurrentUserId()
|
||||||
|
|
||||||
|
// Generate sale number
|
||||||
|
const numero = await generateSaleNumber()
|
||||||
|
|
||||||
|
// Create sale with transaction
|
||||||
|
const sale = await db.$transaction(async (tx) => {
|
||||||
|
// Create sale
|
||||||
|
const vente = await tx.vente.create({
|
||||||
|
data: {
|
||||||
|
numero,
|
||||||
|
clientId: clientId || null,
|
||||||
|
statut: StatutVente.PAYEE,
|
||||||
|
montantHT,
|
||||||
|
montantTVA,
|
||||||
|
montantTTC,
|
||||||
|
remise: remise || 0,
|
||||||
|
notes: notes || null,
|
||||||
|
employeId: null // Can be updated with authentication
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create sale lines and update product stock
|
||||||
|
for (const ligne of lignes) {
|
||||||
|
await tx.ligneVente.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
produitId: ligne.produitId,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prixUnitaireHT: ligne.prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: ligne.prixUnitaireTTC,
|
||||||
|
remise: ligne.remise || 0,
|
||||||
|
montantHT: ligne.montantHT,
|
||||||
|
montantTTC: ligne.montantTTC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update product stock
|
||||||
|
await tx.produit.update({
|
||||||
|
where: { id: ligne.produitId },
|
||||||
|
data: {
|
||||||
|
stock: {
|
||||||
|
decrement: ligne.quantite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create payments
|
||||||
|
for (const paiement of paiements) {
|
||||||
|
await tx.paiement.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
mode: paiement.mode as ModePaiement,
|
||||||
|
montant: paiement.montant,
|
||||||
|
reference: paiement.reference || null,
|
||||||
|
notes: paiement.notes || null,
|
||||||
|
employeId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return vente
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch the complete sale with relations
|
||||||
|
const completeSale = await db.vente.findUnique({
|
||||||
|
where: { id: sale.id },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
employe: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paiements: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(completeSale, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating sale:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create sale' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/app/api/pos/seed/route.ts
Normal file
218
src/app/api/pos/seed/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/app/api/pos/wizard-sale/route.ts
Normal file
238
src/app/api/pos/wizard-sale/route.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { ModePaiement, StatutVente } from '@prisma/client'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { getCurrentUserId, requireAuth } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const SERVICE_TVA = 20
|
||||||
|
|
||||||
|
async function generateSaleNumber(): Promise<string> {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const salesThisMonth = await db.vente.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: new Date(year, today.getMonth(), 1),
|
||||||
|
lt: new Date(year, today.getMonth() + 1, 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return `V${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServiceProduct(reference: string, designation: string) {
|
||||||
|
return db.produit.upsert({
|
||||||
|
where: { reference },
|
||||||
|
update: {
|
||||||
|
designation,
|
||||||
|
actif: true,
|
||||||
|
stock: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
reference,
|
||||||
|
designation,
|
||||||
|
categorie: 'SERVICE',
|
||||||
|
prixAchatHT: 0,
|
||||||
|
prixVenteTTC: 0,
|
||||||
|
tva: SERVICE_TVA,
|
||||||
|
stock: 999999,
|
||||||
|
stockMin: 0,
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHt(ttc: number, tva = SERVICE_TVA) {
|
||||||
|
return ttc / (1 + tva / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAuth()
|
||||||
|
if (authError) return authError
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const clientId = body.clientId || null
|
||||||
|
const serviceType = body.serviceType as 'COMBO' | 'REPAIR'
|
||||||
|
const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement
|
||||||
|
const additionalCharges = Math.max(0, Number(body.additionalCharges || 0))
|
||||||
|
const employeId = await getCurrentUserId()
|
||||||
|
|
||||||
|
const lines: Array<{
|
||||||
|
produitId: string
|
||||||
|
quantite: number
|
||||||
|
prixUnitaireHT: number
|
||||||
|
prixUnitaireTTC: number
|
||||||
|
remise: number
|
||||||
|
montantHT: number
|
||||||
|
montantTTC: number
|
||||||
|
decrementStock: boolean
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
const notes: string[] = []
|
||||||
|
|
||||||
|
if (serviceType === 'COMBO') {
|
||||||
|
const productLines = Array.isArray(body.products) ? body.products : []
|
||||||
|
if (productLines.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Selectionnez au moins un produit' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of productLines) {
|
||||||
|
const product = await db.produit.findUnique({ where: { id: item.produitId } })
|
||||||
|
const quantite = Math.max(1, Number(item.quantite || 1))
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return NextResponse.json({ error: 'Produit introuvable' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.stock < quantite) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Stock insuffisant pour ${product.designation}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prixUnitaireTTC = Number(item.prixUnitaireTTC ?? product.prixVenteTTC)
|
||||||
|
const prixUnitaireHT = toHt(prixUnitaireTTC, product.tva)
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
produitId: product.id,
|
||||||
|
quantite,
|
||||||
|
prixUnitaireHT,
|
||||||
|
prixUnitaireTTC,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixUnitaireHT * quantite,
|
||||||
|
montantTTC: prixUnitaireTTC * quantite,
|
||||||
|
decrementStock: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
notes.push('Service: Pack monture + verres')
|
||||||
|
if (body.details) notes.push(`Details: ${body.details}`)
|
||||||
|
} else if (serviceType === 'REPAIR') {
|
||||||
|
const repairType = String(body.repairType || '').trim()
|
||||||
|
const repairDescription = String(body.repairDescription || '').trim()
|
||||||
|
const repairPrice = Math.max(0, Number(body.repairPrice || 0))
|
||||||
|
|
||||||
|
if (!repairType || !repairDescription) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Type et description de reparation requis' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairProduct = await getServiceProduct('SERVICE_REPAIR', 'Service reparation')
|
||||||
|
const prixUnitaireHT = toHt(repairPrice)
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
produitId: repairProduct.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: repairPrice,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixUnitaireHT,
|
||||||
|
montantTTC: repairPrice,
|
||||||
|
decrementStock: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
notes.push(`Service: Reparation - ${repairType}`)
|
||||||
|
notes.push(`Description: ${repairDescription}`)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Service invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalCharges > 0) {
|
||||||
|
const extraProduct = await getServiceProduct('SERVICE_EXTRA', 'Frais supplementaires')
|
||||||
|
const prixUnitaireHT = toHt(additionalCharges)
|
||||||
|
lines.push({
|
||||||
|
produitId: extraProduct.id,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: additionalCharges,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixUnitaireHT,
|
||||||
|
montantTTC: additionalCharges,
|
||||||
|
decrementStock: false,
|
||||||
|
})
|
||||||
|
notes.push(`Frais supplementaires: ${additionalCharges.toFixed(2)} EUR`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const montantHT = lines.reduce((sum, line) => sum + line.montantHT, 0)
|
||||||
|
const montantTTC = lines.reduce((sum, line) => sum + line.montantTTC, 0)
|
||||||
|
const montantTVA = montantTTC - montantHT
|
||||||
|
const numero = await generateSaleNumber()
|
||||||
|
|
||||||
|
const sale = await db.$transaction(async (tx) => {
|
||||||
|
const vente = await tx.vente.create({
|
||||||
|
data: {
|
||||||
|
numero,
|
||||||
|
clientId,
|
||||||
|
statut: StatutVente.PAYEE,
|
||||||
|
montantHT,
|
||||||
|
montantTVA,
|
||||||
|
montantTTC,
|
||||||
|
remise: 0,
|
||||||
|
notes: notes.join('\n'),
|
||||||
|
employeId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
await tx.ligneVente.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
produitId: line.produitId,
|
||||||
|
quantite: line.quantite,
|
||||||
|
prixUnitaireHT: line.prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: line.prixUnitaireTTC,
|
||||||
|
remise: line.remise,
|
||||||
|
montantHT: line.montantHT,
|
||||||
|
montantTTC: line.montantTTC,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (line.decrementStock) {
|
||||||
|
await tx.produit.update({
|
||||||
|
where: { id: line.produitId },
|
||||||
|
data: {
|
||||||
|
stock: {
|
||||||
|
decrement: line.quantite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.paiement.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
mode: paymentMode,
|
||||||
|
montant: montantTTC,
|
||||||
|
employeId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return vente
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeSale = await db.vente.findUnique({
|
||||||
|
where: { id: sale.id },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paiements: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(completeSale, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Wizard sale error:', error)
|
||||||
|
return NextResponse.json({ error: 'Impossible d enregistrer la vente' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/produits/[id]/images/route.ts
Normal file
29
src/app/api/produits/[id]/images/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src/app/api/produits/[id]/route.ts
Normal file
244
src/app/api/produits/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/app/api/produits/route.ts
Normal file
164
src/app/api/produits/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/produits/upload-image/route.ts
Normal file
94
src/app/api/produits/upload-image/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/app/api/reports/dashboard/route.ts
Normal file
165
src/app/api/reports/dashboard/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/reports/export/inventory/route.ts
Normal file
76
src/app/api/reports/export/inventory/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/reports/export/lowStock/route.ts
Normal file
75
src/app/api/reports/export/lowStock/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/api/reports/export/sales/route.ts
Normal file
127
src/app/api/reports/export/sales/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/app/api/reports/inventory/route.ts
Normal file
114
src/app/api/reports/inventory/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/app/api/reports/sales/route.ts
Normal file
184
src/app/api/reports/sales/route.ts
Normal 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
5
src/app/api/route.ts
Normal 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
122
src/app/globals.css
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/layout.tsx
Normal file
60
src/app/layout.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import SessionProvider from "@/components/auth/SessionProvider";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { CurrencyProvider } from "@/components/currency-provider";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OptiqueStock - Gestion de Magasin d'Optique",
|
||||||
|
description: "Application de gestion de magasin d'optique : clients, produits, ventes, achats, atelier et rapports.",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: "OptiqueStock",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
viewportFit: "cover",
|
||||||
|
themeColor: "#020617",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="fr" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<CurrencyProvider>
|
||||||
|
<SessionProvider>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
</CurrencyProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/login/page.tsx
Normal file
129
src/app/login/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect } from 'react'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const err = searchParams.get('error')
|
||||||
|
if (err === 'CredentialsSignin') {
|
||||||
|
setError('Email ou mot de passe incorrect')
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
await requestFullscreen()
|
||||||
|
|
||||||
|
const callbackUrl = getLocalCallbackPath(searchParams.get('callbackUrl'))
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError('Email ou mot de passe incorrect')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(callbackUrl)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<Card className="w-full max-w-md mx-4">
|
||||||
|
<CardHeader className="text-center space-y-2">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<div className="p-3 bg-primary rounded-lg">
|
||||||
|
<Eye className="h-8 w-8 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">OptiqueStock</CardTitle>
|
||||||
|
<CardDescription>Connectez-vous pour accéder à l'application</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@optiquestock.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? 'Connexion...' : 'Se connecter'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestFullscreen() {
|
||||||
|
if (document.fullscreenElement || !document.documentElement.requestFullscreen) return
|
||||||
|
await document.documentElement.requestFullscreen().catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalCallbackPath(callbackUrl: string | null) {
|
||||||
|
if (!callbackUrl) return '/'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(callbackUrl, window.location.origin)
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}` || '/'
|
||||||
|
} catch {
|
||||||
|
return callbackUrl.startsWith('/') ? callbackUrl : '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<p className="text-gray-500">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
423
src/app/page.tsx
Normal file
423
src/app/page.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { signOut, useSession } from 'next-auth/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
BrainCircuit,
|
||||||
|
Database,
|
||||||
|
Eye,
|
||||||
|
LayoutDashboard,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
Package,
|
||||||
|
PanelTop,
|
||||||
|
ShoppingCart,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
UserCog,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import POSModule from '@/components/pos/POSModule'
|
||||||
|
import { ProduitListe } from '@/components/products/ProduitListe'
|
||||||
|
import { ClientList } from '@/components/clients/client-list'
|
||||||
|
import AtelierModule from '@/components/atelier/AtelierModule'
|
||||||
|
import { SupplierList } from '@/components/suppliers/SupplierList'
|
||||||
|
import PurchaseModule from '@/components/purchases/PurchaseModule'
|
||||||
|
import ReportsModule from '@/components/reports/ReportsModule'
|
||||||
|
import { EmployeeManagement } from '@/components/employees/EmployeeManagement'
|
||||||
|
import { AIAssistant } from '@/components/ai/AIAssistant'
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
|
import { SellerWizard } from '@/components/seller-wizard/SellerWizard'
|
||||||
|
import { CurrencySelect } from '@/components/currency-select'
|
||||||
|
|
||||||
|
type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
|
||||||
|
type Module =
|
||||||
|
| 'HOME'
|
||||||
|
| 'CLIENTS'
|
||||||
|
| 'PRODUITS'
|
||||||
|
| 'FOURNISSEURS'
|
||||||
|
| 'ACHATS'
|
||||||
|
| 'VENTE'
|
||||||
|
| 'RAPPORTS'
|
||||||
|
| 'ATELIER'
|
||||||
|
| 'UTILISATEURS'
|
||||||
|
| 'IA'
|
||||||
|
| 'VENDEUR_WIZARD'
|
||||||
|
|
||||||
|
interface ModuleCard {
|
||||||
|
id: Module
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
badge?: string
|
||||||
|
color: string
|
||||||
|
roles: RoleEmploye[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: ModuleCard[] = [
|
||||||
|
{
|
||||||
|
id: 'VENDEUR_WIZARD',
|
||||||
|
title: 'Vendeur Express',
|
||||||
|
description: 'Parcours rapide client, service, recu',
|
||||||
|
icon: <PanelTop className="h-8 w-8" />,
|
||||||
|
badge: 'Tablette',
|
||||||
|
color: 'bg-rose-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'CLIENTS',
|
||||||
|
title: 'Gestion Clients',
|
||||||
|
description: 'Fiches clients, mesures de vision, ordonnances',
|
||||||
|
icon: <Users className="h-8 w-8" />,
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'PRODUITS',
|
||||||
|
title: 'Gestion Produits',
|
||||||
|
description: 'Catalogue, stock, images, QR codes',
|
||||||
|
icon: <Package className="h-8 w-8" />,
|
||||||
|
badge: 'Alertes',
|
||||||
|
color: 'bg-emerald-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'FOURNISSEURS',
|
||||||
|
title: 'Fournisseurs',
|
||||||
|
description: 'Gestion des fournisseurs et contacts',
|
||||||
|
icon: <Truck className="h-8 w-8" />,
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACHATS',
|
||||||
|
title: 'Achats & Stock',
|
||||||
|
description: 'Reception, factures fournisseurs, entrees stock',
|
||||||
|
icon: <ShoppingCart className="h-8 w-8" />,
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VENTE',
|
||||||
|
title: 'Point de Vente',
|
||||||
|
description: 'Encaissement, facturation, POS',
|
||||||
|
icon: <ShoppingCart className="h-8 w-8" />,
|
||||||
|
badge: 'Actif',
|
||||||
|
color: 'bg-green-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ATELIER',
|
||||||
|
title: 'Atelier',
|
||||||
|
description: 'Montage de lunettes, commandes en cours',
|
||||||
|
icon: <Wrench className="h-8 w-8" />,
|
||||||
|
badge: 'En cours',
|
||||||
|
color: 'bg-amber-500',
|
||||||
|
roles: ['VENDEUR', 'RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RAPPORTS',
|
||||||
|
title: 'Rapports',
|
||||||
|
description: 'Statistiques, exports Excel/CSV/PDF',
|
||||||
|
icon: <BarChart3 className="h-8 w-8" />,
|
||||||
|
color: 'bg-cyan-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'UTILISATEURS',
|
||||||
|
title: 'Utilisateurs',
|
||||||
|
description: 'Employes, roles et niveaux d acces',
|
||||||
|
icon: <UserCog className="h-8 w-8" />,
|
||||||
|
color: 'bg-indigo-500',
|
||||||
|
roles: ['ADMIN'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'IA',
|
||||||
|
title: 'Assistant IA',
|
||||||
|
description: 'Conseils, priorites et aide a la decision',
|
||||||
|
icon: <BrainCircuit className="h-8 w-8" />,
|
||||||
|
badge: 'Nouveau',
|
||||||
|
color: 'bg-sky-500',
|
||||||
|
roles: ['RESPONSABLE', 'ADMIN'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
const [currentModule, setCurrentModule] = useState<Module>('HOME')
|
||||||
|
const [demoGenerating, setDemoGenerating] = useState(false)
|
||||||
|
const [demoMessage, setDemoMessage] = useState('')
|
||||||
|
|
||||||
|
const currentRole = ((session?.user as any)?.role || 'VENDEUR') as RoleEmploye
|
||||||
|
const visibleModules = modules.filter((module) => module.roles.includes(currentRole))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [status, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = modules.find((module) => module.id === currentModule)
|
||||||
|
if (current && !current.roles.includes(currentRole)) {
|
||||||
|
setCurrentModule('HOME')
|
||||||
|
}
|
||||||
|
}, [currentModule, currentRole])
|
||||||
|
|
||||||
|
async function generateDemoData() {
|
||||||
|
setDemoGenerating(true)
|
||||||
|
setDemoMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/demo/seed', { method: 'POST' })
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Generation demo impossible')
|
||||||
|
}
|
||||||
|
|
||||||
|
setDemoMessage(
|
||||||
|
`Demo prete: ${data.clients} clients, ${data.products} produits, ${data.salesCreated} nouvelles ventes.`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
setDemoMessage(error instanceof Error ? error.message : 'Erreur demo inconnue')
|
||||||
|
} finally {
|
||||||
|
setDemoGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') return null
|
||||||
|
|
||||||
|
const moduleInfo = modules.find((module) => module.id === currentModule)
|
||||||
|
|
||||||
|
const moduleHeader = (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour a l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 rounded-lg p-4 ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderModule = () => {
|
||||||
|
if (currentModule === 'HOME') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-foreground">OptiqueStock</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
Systeme de Gestion de Magasin d'Optique
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentRole === 'ADMIN' && (
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col items-center gap-3 rounded-md border bg-card p-4 text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onClick={generateDemoData}
|
||||||
|
disabled={demoGenerating}
|
||||||
|
className="h-12 gap-2"
|
||||||
|
>
|
||||||
|
{demoGenerating ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
Generer les donnees demo
|
||||||
|
</Button>
|
||||||
|
{demoMessage && <p className="text-sm text-muted-foreground">{demoMessage}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{visibleModules.map((module) => (
|
||||||
|
<Card
|
||||||
|
key={module.id}
|
||||||
|
className="group cursor-pointer border-2 transition-all duration-200 hover:scale-105 hover:border-primary hover:shadow-lg"
|
||||||
|
onClick={() => setCurrentModule(module.id)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className={`rounded-lg p-3 ${module.color} text-white`}>{module.icon}</div>
|
||||||
|
{module.badge && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{module.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{module.title}</CardTitle>
|
||||||
|
<CardDescription className="text-sm">{module.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'CLIENTS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<ClientList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'VENTE') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<POSModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'PRODUITS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<ProduitListe />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'ATELIER') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<AtelierModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'FOURNISSEURS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<SupplierList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'ACHATS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<PurchaseModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'RAPPORTS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<ReportsModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'UTILISATEURS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<EmployeeManagement />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'IA') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<AIAssistant />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentModule === 'VENDEUR_WIZARD') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{moduleHeader}
|
||||||
|
<SellerWizard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="border-b bg-card shadow-sm">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-primary p-2">
|
||||||
|
<Eye className="h-6 w-6 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">OptiqueStock</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">Gestion de Magasin d'Optique</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<CurrencySelect />
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{session?.user?.name}</span>
|
||||||
|
<Badge variant="secondary">{currentRole}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => signOut({ redirect: false }).then(() => router.replace('/login'))}
|
||||||
|
title="Se deconnecter"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">{renderModule()}</main>
|
||||||
|
|
||||||
|
<footer className="mt-auto border-t bg-card">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Version 1.0.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/components/ai/AIAssistant.tsx
Normal file
116
src/components/ai/AIAssistant.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Bot, Loader2, Send, Sparkles } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
|
const quickPrompts = [
|
||||||
|
'Resume les priorites du magasin pour aujourd hui.',
|
||||||
|
'Quels produits dois-je recommander de reapprovisionner ?',
|
||||||
|
'Donne-moi 5 idees pour ameliorer les ventes cette semaine.',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AIAssistant() {
|
||||||
|
const [prompt, setPrompt] = useState(quickPrompts[0])
|
||||||
|
const [answer, setAnswer] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function askAssistant() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setAnswer('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/assistant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Assistant IA indisponible')
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnswer(data.answer)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-cyan-500 p-3 text-white">
|
||||||
|
<Sparkles className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Assistant IA</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Analyse le contexte du magasin et propose des actions rapides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Demandes rapides</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{quickPrompts.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
variant={prompt === item ? 'default' : 'outline'}
|
||||||
|
className="h-auto w-full justify-start whitespace-normal text-left"
|
||||||
|
onClick={() => setPrompt(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
Conseiller OptiqueStock
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
className="min-h-32"
|
||||||
|
placeholder="Posez une question sur le stock, les ventes, les priorites..."
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={askAssistant} disabled={loading || !prompt.trim()} className="gap-2">
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Demander
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{answer && (
|
||||||
|
<div className="rounded-md border bg-muted/40 p-4">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-6">{answer}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
930
src/components/atelier/AtelierModule.tsx
Normal file
930
src/components/atelier/AtelierModule.tsx
Normal file
@@ -0,0 +1,930 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Wrench,
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
CheckCircle2,
|
||||||
|
Bell,
|
||||||
|
User,
|
||||||
|
Package,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCheck
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Produit {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
categorie: string
|
||||||
|
marque?: string
|
||||||
|
typeMonture?: string
|
||||||
|
typeVerre?: string
|
||||||
|
materiau?: string
|
||||||
|
couleur?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
prenom: string
|
||||||
|
email?: string
|
||||||
|
telephone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Patient {
|
||||||
|
id: string
|
||||||
|
odSphere?: number
|
||||||
|
odCylindre?: number
|
||||||
|
odAxe?: number
|
||||||
|
ogSphere?: number
|
||||||
|
ogCylindre?: number
|
||||||
|
ogAxe?: number
|
||||||
|
addition?: number
|
||||||
|
pd?: number
|
||||||
|
hauteur?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LigneVente {
|
||||||
|
id: string
|
||||||
|
produit: Produit
|
||||||
|
quantite: number
|
||||||
|
montantHT: number
|
||||||
|
montantTTC: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkOrder {
|
||||||
|
id: string
|
||||||
|
numero: string
|
||||||
|
date: string
|
||||||
|
statutAtelier: 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'PRET' | 'RETIRE'
|
||||||
|
montantTTC: number
|
||||||
|
client?: Client
|
||||||
|
lignes: LigneVente[]
|
||||||
|
patients?: Patient[]
|
||||||
|
dateAtelier?: string
|
||||||
|
dateRetrait?: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'ALL' | 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'PRET' | 'RETIRE'
|
||||||
|
|
||||||
|
export default function AtelierModule() {
|
||||||
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([])
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||||
|
const [showNotifyDialog, setShowNotifyDialog] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<'orders' | 'ready' | 'history'>('orders')
|
||||||
|
|
||||||
|
// Load work orders
|
||||||
|
const loadWorkOrders = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/atelier/orders?XTransformPort=3000')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setWorkOrders(data)
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de charger les commandes atelier',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading work orders:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de charger les commandes atelier',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
const updateOrderStatus = async (orderId: string, newStatus: string) => {
|
||||||
|
console.log('Frontend: updateOrderStatus called')
|
||||||
|
console.log('Frontend: orderId =', orderId)
|
||||||
|
console.log('Frontend: newStatus =', newStatus)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/atelier/orders/${orderId}?XTransformPort=3000`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statutAtelier: newStatus })
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Frontend: response.ok =', response.ok)
|
||||||
|
console.log('Frontend: response.status =', response.status)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Statut mis à jour',
|
||||||
|
description: 'Le statut de la commande a été mis à jour'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
if (selectedOrder?.id === orderId) {
|
||||||
|
setSelectedOrder({ ...selectedOrder, statutAtelier: newStatus as any })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
console.log('Frontend: errorData =', errorData)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: errorData.error || 'Impossible de mettre à jour le statut',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating order status:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de mettre à jour le statut',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View order details
|
||||||
|
const viewOrderDetails = async (order: WorkOrder) => {
|
||||||
|
setSelectedOrder(order)
|
||||||
|
setShowDetailDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as ready for pickup
|
||||||
|
const markAsReady = async () => {
|
||||||
|
if (!selectedOrder) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/atelier/orders/${selectedOrder.id}?XTransformPort=3000`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statutAtelier: 'PRET' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Commande prête',
|
||||||
|
description: 'La commande est marquée comme prête pour le retrait'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
setShowNotifyDialog(false)
|
||||||
|
setShowDetailDialog(false)
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de marquer la commande comme prête',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking order as ready:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de marquer la commande comme prête',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm order pickup (ready → retrieved)
|
||||||
|
const confirmRetrait = async (orderId: string) => {
|
||||||
|
if (!confirm('Confirmer que le client a récupéré ses lunettes ?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/atelier/orders/${orderId}?XTransformPort=3000`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statutAtelier: 'RETIRE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Retrait confirmé',
|
||||||
|
description: 'La commande a été marquée comme retirée'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de confirmer le retrait',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error confirming pickup:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de confirmer le retrait',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkOrders()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter orders
|
||||||
|
const filteredOrders = workOrders.filter(order => {
|
||||||
|
if (statusFilter === 'ALL') return true
|
||||||
|
return order.statutAtelier === statusFilter
|
||||||
|
})
|
||||||
|
|
||||||
|
const readyOrders = workOrders.filter(order => order.statutAtelier === 'PRET')
|
||||||
|
|
||||||
|
// Get status badge
|
||||||
|
const getStatusBadge = (statut: string) => {
|
||||||
|
const variants: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||||
|
'EN_ATTENTE': {
|
||||||
|
color: 'bg-gray-500',
|
||||||
|
label: 'En attente',
|
||||||
|
icon: <Clock className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'EN_COURS': {
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
label: 'En cours',
|
||||||
|
icon: <Play className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'TERMINE': {
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
label: 'Terminé',
|
||||||
|
icon: <CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'PRET': {
|
||||||
|
color: 'bg-green-500',
|
||||||
|
label: 'Prêt',
|
||||||
|
icon: <Bell className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'RETIRE': {
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
label: 'Retiré',
|
||||||
|
icon: <CheckCheck className="h-3 w-3 mr-1" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = variants[statut] || variants['EN_ATTENTE']
|
||||||
|
return (
|
||||||
|
<Badge className={status.color}>
|
||||||
|
{status.icon}
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format vision data
|
||||||
|
const formatVisionData = (patient?: Patient) => {
|
||||||
|
if (!patient) return null
|
||||||
|
return {
|
||||||
|
od: `Sph: ${patient.odSphere || '-'} | Cyl: ${patient.odCylindre || '-'} | Axe: ${patient.odAxe || '-'}`,
|
||||||
|
og: `Sph: ${patient.ogSphere || '-'} | Cyl: ${patient.ogCylindre || '-'} | Axe: ${patient.ogAxe || '-'}`,
|
||||||
|
addition: patient.addition ? `Add: ${patient.addition}` : null,
|
||||||
|
pd: patient.pd ? `PD: ${patient.pd}mm` : null,
|
||||||
|
hauteur: patient.hauteur ? `Haut: ${patient.hauteur}mm` : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">En attente</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'EN_ATTENTE').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">En cours</CardTitle>
|
||||||
|
<Play className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'EN_COURS').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Terminé</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-purple-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'TERMINE').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Prêt à retirer</CardTitle>
|
||||||
|
<Bell className="h-4 w-4 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'PRET').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Retiré</CardTitle>
|
||||||
|
<CheckCheck className="h-4 w-4 text-orange-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'orders' | 'ready')}>
|
||||||
|
<TabsList className="grid w-full max-w-lg grid-cols-3">
|
||||||
|
<TabsTrigger value="orders">Commandes</TabsTrigger>
|
||||||
|
<TabsTrigger value="ready">
|
||||||
|
Prêtes
|
||||||
|
{readyOrders.length > 0 && (
|
||||||
|
<Badge className="ml-2 bg-green-500">{readyOrders.length}</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">
|
||||||
|
Historique
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length > 0 && (
|
||||||
|
<Badge className="ml-2 bg-orange-500">{workOrders.filter(o => o.statutAtelier === 'RETIRE').length}</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="orders" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5" />
|
||||||
|
Commandes de Montage
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Gérez les commandes de montage de lunettes
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'ALL' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('ALL')}
|
||||||
|
>
|
||||||
|
Toutes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'EN_ATTENTE' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('EN_ATTENTE')}
|
||||||
|
>
|
||||||
|
En attente
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'EN_COURS' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('EN_COURS')}
|
||||||
|
>
|
||||||
|
En cours
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'TERMINE' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('TERMINE')}
|
||||||
|
>
|
||||||
|
Terminé
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'RETIRE' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('RETIRE')}
|
||||||
|
>
|
||||||
|
Retiré
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : filteredOrders.length === 0 ? (
|
||||||
|
<div className="text-center py-8 space-y-4">
|
||||||
|
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{statusFilter === 'ALL'
|
||||||
|
? 'Aucune commande de montage en cours'
|
||||||
|
: statusFilter === 'RETIRE'
|
||||||
|
? 'Aucune commande retirée'
|
||||||
|
: `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[600px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>N° Commande</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Produits</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-medium">{order.numero}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(order.date).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.client
|
||||||
|
? `${order.client.prenom} ${order.client.nom}`
|
||||||
|
: 'Client anonyme'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{order.lignes.slice(0, 2).map((ligne) => (
|
||||||
|
<span key={ligne.id} className="text-xs">
|
||||||
|
{ligne.produit.designation}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{order.lignes.length > 2 && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
+{order.lignes.length - 2} autre(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(order.statutAtelier)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => viewOrderDetails(order)}
|
||||||
|
>
|
||||||
|
Voir détails
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ready">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5 text-green-500" />
|
||||||
|
Commandes prêtes pour retrait
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Les commandes terminées et prêtes à être remises aux clients
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{readyOrders.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Aucune commande prête pour le retrait
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[600px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>N° Commande</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Téléphone</TableHead>
|
||||||
|
<TableHead>Prêt depuis</TableHead>
|
||||||
|
<TableHead className="text-right">Montant TTC</TableHead>
|
||||||
|
<TableHead className="text-center">Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{readyOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-medium">{order.numero}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(order.date).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.client
|
||||||
|
? `${order.client.prenom} ${order.client.nom}`
|
||||||
|
: 'Client anonyme'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.client?.telephone || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.dateAtelier
|
||||||
|
? new Date(order.dateAtelier).toLocaleDateString('fr-FR')
|
||||||
|
: new Date(order.date).toLocaleDateString('fr-FR')
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{order.montantTTC.toFixed(2)} €
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => confirmRetrait(order.id)}
|
||||||
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-4 w-4 mr-2" />
|
||||||
|
Confirmer retrait
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* History Tab */}
|
||||||
|
<TabsContent value="history" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCheck className="h-5 w-5 text-orange-500" />
|
||||||
|
Historique des retraits
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Les commandes qui ont été 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/components/auth/SessionProvider.tsx
Normal file
7
src/components/auth/SessionProvider.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
|
||||||
|
|
||||||
|
export default function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
|
||||||
|
}
|
||||||
396
src/components/clients/client-detail.tsx
Normal file
396
src/components/clients/client-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
281
src/components/clients/client-form.tsx
Normal file
281
src/components/clients/client-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
365
src/components/clients/client-list.tsx
Normal file
365
src/components/clients/client-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
378
src/components/clients/ordonnance-list.tsx
Normal file
378
src/components/clients/ordonnance-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
369
src/components/clients/vision-form.tsx
Normal file
369
src/components/clients/vision-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/components/currency-provider.tsx
Normal file
57
src/components/currency-provider.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export type CurrencyCode = 'MAD' | 'EUR' | 'USD'
|
||||||
|
|
||||||
|
const currencyLocales: Record<CurrencyCode, string> = {
|
||||||
|
MAD: 'fr-MA',
|
||||||
|
EUR: 'fr-FR',
|
||||||
|
USD: 'en-US',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrencyContextValue {
|
||||||
|
currency: CurrencyCode
|
||||||
|
setCurrency: (currency: CurrencyCode) => void
|
||||||
|
formatCurrency: (value: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CurrencyContext = createContext<CurrencyContextValue | null>(null)
|
||||||
|
|
||||||
|
export function CurrencyProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [currency, setCurrencyState] = useState<CurrencyCode>('MAD')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem('optiquestock-currency') as CurrencyCode | null
|
||||||
|
if (stored === 'MAD' || stored === 'EUR' || stored === 'USD') {
|
||||||
|
setCurrencyState(stored)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<CurrencyContextValue>(() => {
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
setCurrency(nextCurrency) {
|
||||||
|
setCurrencyState(nextCurrency)
|
||||||
|
window.localStorage.setItem('optiquestock-currency', nextCurrency)
|
||||||
|
},
|
||||||
|
formatCurrency(amount) {
|
||||||
|
return new Intl.NumberFormat(currencyLocales[currency], {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [currency])
|
||||||
|
|
||||||
|
return <CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrency() {
|
||||||
|
const context = useContext(CurrencyContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCurrency must be used within CurrencyProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
25
src/components/currency-select.tsx
Normal file
25
src/components/currency-select.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { BadgeDollarSign } from 'lucide-react'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { CurrencyCode, useCurrency } from '@/components/currency-provider'
|
||||||
|
|
||||||
|
export function CurrencySelect() {
|
||||||
|
const { currency, setCurrency } = useCurrency()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BadgeDollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select value={currency} onValueChange={(value: CurrencyCode) => setCurrency(value)}>
|
||||||
|
<SelectTrigger className="h-9 w-[92px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="MAD">MAD</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
468
src/components/employees/EmployeeManagement.tsx
Normal file
468
src/components/employees/EmployeeManagement.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Plus, Search, Shield, Trash2, UserCog, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
nom: string
|
||||||
|
prenom: string
|
||||||
|
role: RoleEmploye
|
||||||
|
actif: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
_count?: {
|
||||||
|
ventes: number
|
||||||
|
facturesAchat: number
|
||||||
|
paiements: number
|
||||||
|
patients: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabels: Record<RoleEmploye, string> = {
|
||||||
|
VENDEUR: 'Vendeur',
|
||||||
|
RESPONSABLE: 'Responsable',
|
||||||
|
ADMIN: 'Administrateur',
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
email: '',
|
||||||
|
nom: '',
|
||||||
|
prenom: '',
|
||||||
|
role: 'VENDEUR' as RoleEmploye,
|
||||||
|
actif: true,
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeManagement() {
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmployees()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredEmployees = useMemo(() => {
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
return employees.filter((employee) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!term ||
|
||||||
|
employee.nom.toLowerCase().includes(term) ||
|
||||||
|
employee.prenom.toLowerCase().includes(term) ||
|
||||||
|
employee.email.toLowerCase().includes(term) ||
|
||||||
|
roleLabels[employee.role].toLowerCase().includes(term)
|
||||||
|
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === 'all' ||
|
||||||
|
(statusFilter === 'active' && employee.actif) ||
|
||||||
|
(statusFilter === 'inactive' && !employee.actif)
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [employees, searchTerm, statusFilter])
|
||||||
|
|
||||||
|
const activeCount = employees.filter((employee) => employee.actif).length
|
||||||
|
const adminCount = employees.filter((employee) => employee.actif && employee.role === 'ADMIN').length
|
||||||
|
|
||||||
|
async function fetchEmployees() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/employes')
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Erreur lors du chargement des employes')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmployees(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditingEmployee(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setError('')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(employee: Employee) {
|
||||||
|
setEditingEmployee(employee)
|
||||||
|
setForm({
|
||||||
|
email: employee.email,
|
||||||
|
nom: employee.nom,
|
||||||
|
prenom: employee.prenom,
|
||||||
|
role: employee.role,
|
||||||
|
actif: employee.actif,
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmployee(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
email: form.email,
|
||||||
|
nom: form.nom,
|
||||||
|
prenom: form.prenom,
|
||||||
|
role: form.role,
|
||||||
|
actif: form.actif,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
editingEmployee ? `/api/employes/${editingEmployee.id}` : '/api/employes',
|
||||||
|
{
|
||||||
|
method: editingEmployee ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Enregistrement impossible')
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingEmployee(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
await fetchEmployees()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEmployee(employee: Employee) {
|
||||||
|
if (!confirm(`Supprimer ou desactiver ${employee.prenom} ${employee.nom} ?`)) return
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
const response = await fetch(`/api/employes/${employee.id}`, { method: 'DELETE' })
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Suppression impossible')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchEmployees()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Utilisateurs et acces</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Gere les employes, leur statut et leur niveau d'acces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={openCreateDialog} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nouvel employe
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingEmployee ? 'Modifier un employe' : 'Nouvel employe'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Les administrateurs peuvent gerer les utilisateurs et les acces.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={saveEmployee} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prenom">Prenom</Label>
|
||||||
|
<Input
|
||||||
|
id="prenom"
|
||||||
|
value={form.prenom}
|
||||||
|
onChange={(event) => setForm({ ...form, prenom: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nom">Nom</Label>
|
||||||
|
<Input
|
||||||
|
id="nom"
|
||||||
|
value={form.nom}
|
||||||
|
onChange={(event) => setForm({ ...form, nom: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
{editingEmployee ? 'Nouveau mot de passe' : 'Mot de passe'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||||
|
required={!editingEmployee}
|
||||||
|
placeholder={editingEmployee ? 'Laisser vide pour conserver' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Niveau d'acces</Label>
|
||||||
|
<Select
|
||||||
|
value={form.role}
|
||||||
|
onValueChange={(value: RoleEmploye) => setForm({ ...form, role: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="VENDEUR">Vendeur</SelectItem>
|
||||||
|
<SelectItem value="RESPONSABLE">Responsable</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">Administrateur</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<Label>Compte actif</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Autorise la connexion</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.actif}
|
||||||
|
onCheckedChange={(checked) => setForm({ ...form, actif: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
|
<p className="text-2xl font-bold">{employees.length}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Comptes actifs</p>
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">{activeCount}</p>
|
||||||
|
</div>
|
||||||
|
<UserCog className="h-8 w-8 text-emerald-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Administrateurs</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{adminCount}</p>
|
||||||
|
</div>
|
||||||
|
<Shield className="h-8 w-8 text-blue-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !dialogOpen && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Recherche et filtres</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par nom, email ou role..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'active' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
>
|
||||||
|
Actifs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'inactive' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
>
|
||||||
|
Inactifs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ScrollArea className="h-[560px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Employe</TableHead>
|
||||||
|
<TableHead>Acces</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead>Activite liee</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-8 text-center">
|
||||||
|
Chargement...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredEmployees.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||||
|
Aucun employe trouve
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredEmployees.map((employee) => (
|
||||||
|
<TableRow key={employee.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">
|
||||||
|
{employee.prenom} {employee.nom}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{employee.email}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={employee.role === 'ADMIN' ? 'default' : 'secondary'}>
|
||||||
|
{roleLabels[employee.role]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={employee.actif ? 'default' : 'secondary'}>
|
||||||
|
{employee.actif ? 'Actif' : 'Inactif'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{(employee._count?.ventes || 0) + (employee._count?.facturesAchat || 0)} operations
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEditDialog(employee)}>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteEmployee(employee)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user