Initial commit
This commit is contained in:
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-*
|
||||||
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
local-*
|
||||||
|
.claude
|
||||||
|
.z-ai-config
|
||||||
|
dev.log
|
||||||
|
test
|
||||||
|
prompt
|
||||||
|
|
||||||
|
server.log
|
||||||
|
# Skills directory
|
||||||
|
/skills/
|
||||||
117
.zscripts/build.sh
Normal file
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
README.md
Normal file
141
README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 🚀 Welcome to Z.ai Code Scaffold
|
||||||
|
|
||||||
|
A modern, production-ready web application scaffold powered by cutting-edge technologies, designed to accelerate your development with [Z.ai](https://chat.z.ai)'s AI-powered coding assistance.
|
||||||
|
|
||||||
|
## ✨ Technology Stack
|
||||||
|
|
||||||
|
This scaffold provides a robust foundation built with:
|
||||||
|
|
||||||
|
### 🎯 Core Framework
|
||||||
|
- **⚡ Next.js 16** - The React framework for production with App Router
|
||||||
|
- **📘 TypeScript 5** - Type-safe JavaScript for better developer experience
|
||||||
|
- **🎨 Tailwind CSS 4** - Utility-first CSS framework for rapid UI development
|
||||||
|
|
||||||
|
### 🧩 UI Components & Styling
|
||||||
|
- **🧩 shadcn/ui** - High-quality, accessible components built on Radix UI
|
||||||
|
- **🎯 Lucide React** - Beautiful & consistent icon library
|
||||||
|
- **🌈 Framer Motion** - Production-ready motion library for React
|
||||||
|
- **🎨 Next Themes** - Perfect dark mode in 2 lines of code
|
||||||
|
|
||||||
|
### 📋 Forms & Validation
|
||||||
|
- **🎣 React Hook Form** - Performant forms with easy validation
|
||||||
|
- **✅ Zod** - TypeScript-first schema validation
|
||||||
|
|
||||||
|
### 🔄 State Management & Data Fetching
|
||||||
|
- **🐻 Zustand** - Simple, scalable state management
|
||||||
|
- **🔄 TanStack Query** - Powerful data synchronization for React
|
||||||
|
- **🌐 Fetch** - Promise-based HTTP request
|
||||||
|
|
||||||
|
### 🗄️ Database & Backend
|
||||||
|
- **🗄️ Prisma** - Next-generation TypeScript ORM
|
||||||
|
- **🔐 NextAuth.js** - Complete open-source authentication solution
|
||||||
|
|
||||||
|
### 🎨 Advanced UI Features
|
||||||
|
- **📊 TanStack Table** - Headless UI for building tables and datagrids
|
||||||
|
- **🖱️ DND Kit** - Modern drag and drop toolkit for React
|
||||||
|
- **📊 Recharts** - Redefined chart library built with React and D3
|
||||||
|
- **🖼️ Sharp** - High performance image processing
|
||||||
|
|
||||||
|
### 🌍 Internationalization & Utilities
|
||||||
|
- **🌍 Next Intl** - Internationalization library for Next.js
|
||||||
|
- **📅 Date-fns** - Modern JavaScript date utility library
|
||||||
|
- **🪝 ReactUse** - Collection of essential React hooks for modern development
|
||||||
|
|
||||||
|
## 🎯 Why This Scaffold?
|
||||||
|
|
||||||
|
- **🏎️ Fast Development** - Pre-configured tooling and best practices
|
||||||
|
- **🎨 Beautiful UI** - Complete shadcn/ui component library with advanced interactions
|
||||||
|
- **🔒 Type Safety** - Full TypeScript configuration with Zod validation
|
||||||
|
- **📱 Responsive** - Mobile-first design principles with smooth animations
|
||||||
|
- **🗄️ Database Ready** - Prisma ORM configured for rapid backend development
|
||||||
|
- **🔐 Auth Included** - NextAuth.js for secure authentication flows
|
||||||
|
- **📊 Data Visualization** - Charts, tables, and drag-and-drop functionality
|
||||||
|
- **🌍 i18n Ready** - Multi-language support with Next Intl
|
||||||
|
- **🚀 Production Ready** - Optimized build and deployment settings
|
||||||
|
- **🤖 AI-Friendly** - Structured codebase perfect for AI assistance
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to see your application running.
|
||||||
|
|
||||||
|
## 🤖 Powered by Z.ai
|
||||||
|
|
||||||
|
This scaffold is optimized for use with [Z.ai](https://chat.z.ai) - your AI assistant for:
|
||||||
|
|
||||||
|
- **💻 Code Generation** - Generate components, pages, and features instantly
|
||||||
|
- **🎨 UI Development** - Create beautiful interfaces with AI assistance
|
||||||
|
- **🔧 Bug Fixing** - Identify and resolve issues with intelligent suggestions
|
||||||
|
- **📝 Documentation** - Auto-generate comprehensive documentation
|
||||||
|
- **🚀 Optimization** - Performance improvements and best practices
|
||||||
|
|
||||||
|
Ready to build something amazing? Start chatting with Z.ai at [chat.z.ai](https://chat.z.ai) and experience the future of AI-powered development!
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router pages
|
||||||
|
├── components/ # Reusable React components
|
||||||
|
│ └── ui/ # shadcn/ui components
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
└── lib/ # Utility functions and configurations
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Available Features & Components
|
||||||
|
|
||||||
|
This scaffold includes a comprehensive set of modern web development tools:
|
||||||
|
|
||||||
|
### 🧩 UI Components (shadcn/ui)
|
||||||
|
- **Layout**: Card, Separator, Aspect Ratio, Resizable Panels
|
||||||
|
- **Forms**: Input, Textarea, Select, Checkbox, Radio Group, Switch
|
||||||
|
- **Feedback**: Alert, Toast (Sonner), Progress, Skeleton
|
||||||
|
- **Navigation**: Breadcrumb, Menubar, Navigation Menu, Pagination
|
||||||
|
- **Overlay**: Dialog, Sheet, Popover, Tooltip, Hover Card
|
||||||
|
- **Data Display**: Badge, Avatar, Calendar
|
||||||
|
|
||||||
|
### 📊 Advanced Data Features
|
||||||
|
- **Tables**: Powerful data tables with sorting, filtering, pagination (TanStack Table)
|
||||||
|
- **Charts**: Beautiful visualizations with Recharts
|
||||||
|
- **Forms**: Type-safe forms with React Hook Form + Zod validation
|
||||||
|
|
||||||
|
### 🎨 Interactive Features
|
||||||
|
- **Animations**: Smooth micro-interactions with Framer Motion
|
||||||
|
- **Drag & Drop**: Modern drag-and-drop functionality with DND Kit
|
||||||
|
- **Theme Switching**: Built-in dark/light mode support
|
||||||
|
|
||||||
|
### 🔐 Backend Integration
|
||||||
|
- **Authentication**: Ready-to-use auth flows with NextAuth.js
|
||||||
|
- **Database**: Type-safe database operations with Prisma
|
||||||
|
- **API Client**: HTTP requests with Fetch + TanStack Query
|
||||||
|
- **State Management**: Simple and scalable with Zustand
|
||||||
|
|
||||||
|
### 🌍 Production Features
|
||||||
|
- **Internationalization**: Multi-language support with Next Intl
|
||||||
|
- **Image Optimization**: Automatic image processing with Sharp
|
||||||
|
- **Type Safety**: End-to-end TypeScript with Zod validation
|
||||||
|
- **Essential Hooks**: 100+ useful React hooks with ReactUse for common patterns
|
||||||
|
|
||||||
|
## 🤝 Get Started with Z.ai
|
||||||
|
|
||||||
|
1. **Clone this scaffold** to jumpstart your project
|
||||||
|
2. **Visit [chat.z.ai](https://chat.z.ai)** to access your AI coding assistant
|
||||||
|
3. **Start building** with intelligent code generation and assistance
|
||||||
|
4. **Deploy with confidence** using the production-ready setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ for the developer community. Supercharged by [Z.ai](https://chat.z.ai) 🚀
|
||||||
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
12
next.config.ts
Normal file
12
next.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
/* config options here */
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
reactStrictMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
12480
package-lock.json
generated
Normal file
12480
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
96
package.json
Normal file
96
package.json
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"name": "nextjs_tailwind_shadcn_ts",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
||||||
|
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||||
|
"start": "NODE_ENV=production bun .next/standalone/server.js 2>&1 | tee server.log",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:reset": "prisma migrate reset"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@mdxeditor/editor": "^3.39.1",
|
||||||
|
"@prisma/client": "^6.11.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.15",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@reactuses/core": "^6.0.5",
|
||||||
|
"@tanstack/react-query": "^5.82.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^12.23.2",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
|
"next": "^16.1.1",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
|
"next-intl": "^4.3.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"prisma": "^6.11.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.60.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"z-ai-web-dev-sdk": "^0.0.16",
|
||||||
|
"zod": "^4.0.2",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"bun-types": "^1.3.4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^16.1.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.3.5",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
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 |
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/app/api/pos/sales/route.ts
Normal file
192
src/app/api/pos/sales/route.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { StatutVente, ModePaiement } from '@prisma/client'
|
||||||
|
|
||||||
|
// Helper function to generate sale number
|
||||||
|
async function generateSaleNumber(): Promise<string> {
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
|
||||||
|
// Count sales for this month
|
||||||
|
const salesThisMonth = await db.vente.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: new Date(year, today.getMonth(), 1),
|
||||||
|
lt: new Date(year, today.getMonth() + 1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sequence = String(salesThisMonth + 1).padStart(4, '0')
|
||||||
|
return `V${year}${month}${sequence}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/pos/sales - Get all sales with relations
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sales = await db.vente.findMany({
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
employe: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paiements: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
take: 50 // Limit to last 50 sales
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(sales)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sales:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch sales' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/pos/sales - Create a new sale
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
clientId,
|
||||||
|
lignes,
|
||||||
|
paiements,
|
||||||
|
remise,
|
||||||
|
montantHT,
|
||||||
|
montantTVA,
|
||||||
|
montantTTC,
|
||||||
|
notes
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!lignes || lignes.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Sale must have at least one line' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paiements || paiements.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Sale must have at least one payment' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify product availability
|
||||||
|
for (const ligne of lignes) {
|
||||||
|
const product = await db.produit.findUnique({
|
||||||
|
where: { id: ligne.produitId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Product ${ligne.produitId} not found` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.stock < ligne.quantite) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Insufficient stock for product ${product.designation}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sale number
|
||||||
|
const numero = await generateSaleNumber()
|
||||||
|
|
||||||
|
// Create sale with transaction
|
||||||
|
const sale = await db.$transaction(async (tx) => {
|
||||||
|
// Create sale
|
||||||
|
const vente = await tx.vente.create({
|
||||||
|
data: {
|
||||||
|
numero,
|
||||||
|
clientId: clientId || null,
|
||||||
|
statut: StatutVente.PAYEE,
|
||||||
|
montantHT,
|
||||||
|
montantTVA,
|
||||||
|
montantTTC,
|
||||||
|
remise: remise || 0,
|
||||||
|
notes: notes || null,
|
||||||
|
employeId: null // Can be updated with authentication
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create sale lines and update product stock
|
||||||
|
for (const ligne of lignes) {
|
||||||
|
await tx.ligneVente.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
produitId: ligne.produitId,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prixUnitaireHT: ligne.prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: ligne.prixUnitaireTTC,
|
||||||
|
remise: ligne.remise || 0,
|
||||||
|
montantHT: ligne.montantHT,
|
||||||
|
montantTTC: ligne.montantTTC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update product stock
|
||||||
|
await tx.produit.update({
|
||||||
|
where: { id: ligne.produitId },
|
||||||
|
data: {
|
||||||
|
stock: {
|
||||||
|
decrement: ligne.quantite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create payments
|
||||||
|
for (const paiement of paiements) {
|
||||||
|
await tx.paiement.create({
|
||||||
|
data: {
|
||||||
|
venteId: vente.id,
|
||||||
|
mode: paiement.mode as ModePaiement,
|
||||||
|
montant: paiement.montant,
|
||||||
|
reference: paiement.reference || null,
|
||||||
|
notes: paiement.notes || null,
|
||||||
|
employeId: null // Can be updated with authentication
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return vente
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch the complete sale with relations
|
||||||
|
const completeSale = await db.vente.findUnique({
|
||||||
|
where: { id: sale.id },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
employe: true,
|
||||||
|
lignes: {
|
||||||
|
include: {
|
||||||
|
produit: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paiements: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(completeSale, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating sale:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create sale' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/layout.tsx
Normal file
53
src/app/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Z.ai Code Scaffold - AI-Powered Development",
|
||||||
|
description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.",
|
||||||
|
keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"],
|
||||||
|
authors: [{ name: "Z.ai Team" }],
|
||||||
|
icons: {
|
||||||
|
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: "Z.ai Code Scaffold",
|
||||||
|
description: "AI-powered development with modern React stack",
|
||||||
|
url: "https://chat.z.ai",
|
||||||
|
siteName: "Z.ai",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Z.ai Code Scaffold",
|
||||||
|
description: "AI-powered development with modern React stack",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
378
src/app/page.tsx
Normal file
378
src/app/page.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
ShoppingCart,
|
||||||
|
FileText,
|
||||||
|
BarChart3,
|
||||||
|
Eye,
|
||||||
|
LayoutDashboard,
|
||||||
|
Wrench
|
||||||
|
} from 'lucide-react'
|
||||||
|
import POSModule from '@/components/pos/POSModule'
|
||||||
|
import { ProduitListe } from '@/components/products/ProduitListe'
|
||||||
|
import { ClientList } from '@/components/clients/client-list'
|
||||||
|
import AtelierModule from '@/components/atelier/AtelierModule'
|
||||||
|
import { SupplierList } from '@/components/suppliers/SupplierList'
|
||||||
|
import PurchaseModule from '@/components/purchases/PurchaseModule'
|
||||||
|
import ReportsModule from '@/components/reports/ReportsModule'
|
||||||
|
|
||||||
|
type Module = 'HOME' | 'CLIENTS' | 'PRODUITS' | 'FOURNISSEURS' | 'ACHATS' | 'VENTE' | 'RAPPORTS' | 'ATELIER'
|
||||||
|
|
||||||
|
interface ModuleCard {
|
||||||
|
id: Module
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
badge?: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: ModuleCard[] = [
|
||||||
|
{
|
||||||
|
id: 'CLIENTS',
|
||||||
|
title: 'Gestion Clients',
|
||||||
|
description: 'Fiches clients, mesures de vision, ordonnances',
|
||||||
|
icon: <Users className="h-8 w-8" />,
|
||||||
|
color: 'bg-blue-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'PRODUITS',
|
||||||
|
title: 'Gestion Produits',
|
||||||
|
description: 'Catalogue, stock, images, QR codes',
|
||||||
|
icon: <Package className="h-8 w-8" />,
|
||||||
|
badge: 'Alertes',
|
||||||
|
color: 'bg-emerald-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'FOURNISSEURS',
|
||||||
|
title: 'Fournisseurs',
|
||||||
|
description: 'Gestion des fournisseurs et contacts',
|
||||||
|
icon: <Truck className="h-8 w-8" />,
|
||||||
|
color: 'bg-orange-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACHATS',
|
||||||
|
title: 'Achats & Stock',
|
||||||
|
description: 'Réception, factures fournisseurs, entrées stock',
|
||||||
|
icon: <ShoppingCart className="h-8 w-8" />,
|
||||||
|
color: 'bg-purple-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VENTE',
|
||||||
|
title: 'Point de Vente',
|
||||||
|
description: 'Encaissement, facturation, POS',
|
||||||
|
icon: <ShoppingCart className="h-8 w-8" />,
|
||||||
|
badge: 'Actif',
|
||||||
|
color: 'bg-green-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ATELIER',
|
||||||
|
title: 'Atelier',
|
||||||
|
description: 'Montage de lunettes, commandes en cours',
|
||||||
|
icon: <Wrench className="h-8 w-8" />,
|
||||||
|
badge: 'En cours',
|
||||||
|
color: 'bg-amber-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'RAPPORTS',
|
||||||
|
title: 'Rapports',
|
||||||
|
description: 'Statistiques, exports Excel/CSV/PDF',
|
||||||
|
icon: <BarChart3 className="h-8 w-8" />,
|
||||||
|
color: 'bg-cyan-500'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [currentModule, setCurrentModule] = useState<Module>('HOME')
|
||||||
|
|
||||||
|
const renderModule = () => {
|
||||||
|
if (currentModule === 'HOME') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900">OptiqueStock</h1>
|
||||||
|
<p className="text-lg text-gray-600">Système de Gestion de Magasin d'Optique</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<Card
|
||||||
|
key={module.id}
|
||||||
|
className="group cursor-pointer transition-all duration-200 hover:shadow-lg hover:scale-105 border-2 hover:border-primary"
|
||||||
|
onClick={() => setCurrentModule(module.id)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className={`flex items-center justify-between mb-2`}>
|
||||||
|
<div className={`p-3 rounded-lg ${module.color} text-white`}>
|
||||||
|
{module.icon}
|
||||||
|
</div>
|
||||||
|
{module.badge && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{module.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{module.title}</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
{module.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleInfo = modules.find(m => m.id === currentModule)
|
||||||
|
|
||||||
|
// Render Client Management module if selected
|
||||||
|
if (currentModule === 'CLIENTS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ClientList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render POS module if selected
|
||||||
|
if (currentModule === 'VENTE') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<POSModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Products module if selected
|
||||||
|
if (currentModule === 'PRODUITS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProduitListe />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Atelier module if selected
|
||||||
|
if (currentModule === 'ATELIER') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AtelierModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Suppliers module if selected
|
||||||
|
if (currentModule === 'FOURNISSEURS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SupplierList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Purchases module if selected
|
||||||
|
if (currentModule === 'ACHATS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PurchaseModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Reports module if selected
|
||||||
|
if (currentModule === 'RAPPORTS') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReportsModule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCurrentModule('HOME')}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${moduleInfo?.color} text-white`}>
|
||||||
|
{moduleInfo?.icon}
|
||||||
|
<h2 className="text-2xl font-bold">{moduleInfo?.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-2 border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className={`p-6 rounded-full ${moduleInfo?.color} bg-opacity-10 mb-4`}>
|
||||||
|
<Eye className="h-16 w-16 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-700 mb-2">
|
||||||
|
Module en développement
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 text-center max-w-md">
|
||||||
|
Le module <strong>{moduleInfo?.title}</strong> est actuellement en cours de développement.
|
||||||
|
Veuillez revenir ultérieurement.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<header className="bg-white border-b shadow-sm">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary rounded-lg">
|
||||||
|
<Eye className="h-6 w-6 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">OptiqueStock</h1>
|
||||||
|
<p className="text-xs text-gray-500">Gestion de Magasin d'Optique</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline" className="text-sm">
|
||||||
|
v1.0.0
|
||||||
|
</Badge>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
|
||||||
|
OP
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
{renderModule()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-white border-t mt-auto">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
© 2024 OptiqueStock. Tous droits réservés.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span>Support: support@optiquestock.com</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Version 1.0.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
965
src/components/atelier/AtelierModule.tsx
Normal file
965
src/components/atelier/AtelierModule.tsx
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Wrench,
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
CheckCircle2,
|
||||||
|
Bell,
|
||||||
|
User,
|
||||||
|
Package,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCheck
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Produit {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
categorie: string
|
||||||
|
marque?: string
|
||||||
|
typeMonture?: string
|
||||||
|
typeVerre?: string
|
||||||
|
materiau?: string
|
||||||
|
couleur?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
prenom: string
|
||||||
|
email?: string
|
||||||
|
telephone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Patient {
|
||||||
|
id: string
|
||||||
|
odSphere?: number
|
||||||
|
odCylindre?: number
|
||||||
|
odAxe?: number
|
||||||
|
ogSphere?: number
|
||||||
|
ogCylindre?: number
|
||||||
|
ogAxe?: number
|
||||||
|
addition?: number
|
||||||
|
pd?: number
|
||||||
|
hauteur?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LigneVente {
|
||||||
|
id: string
|
||||||
|
produit: Produit
|
||||||
|
quantite: number
|
||||||
|
montantHT: number
|
||||||
|
montantTTC: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkOrder {
|
||||||
|
id: string
|
||||||
|
numero: string
|
||||||
|
date: string
|
||||||
|
statutAtelier: 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'PRET' | 'RETIRE'
|
||||||
|
montantTTC: number
|
||||||
|
client?: Client
|
||||||
|
lignes: LigneVente[]
|
||||||
|
patients?: Patient[]
|
||||||
|
dateAtelier?: string
|
||||||
|
dateRetrait?: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'ALL' | 'EN_ATTENTE' | 'EN_COURS' | 'TERMINE' | 'PRET' | 'RETIRE'
|
||||||
|
|
||||||
|
export default function AtelierModule() {
|
||||||
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([])
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||||
|
const [showNotifyDialog, setShowNotifyDialog] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<'orders' | 'ready' | 'history'>('orders')
|
||||||
|
|
||||||
|
// Load work orders
|
||||||
|
const loadWorkOrders = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/atelier/orders?XTransformPort=3000')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setWorkOrders(data)
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de charger les commandes atelier',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading work orders:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de charger les commandes atelier',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
const updateOrderStatus = async (orderId: string, newStatus: string) => {
|
||||||
|
console.log('Frontend: updateOrderStatus called')
|
||||||
|
console.log('Frontend: orderId =', orderId)
|
||||||
|
console.log('Frontend: newStatus =', newStatus)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/atelier/orders/${orderId}?XTransformPort=3000`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statutAtelier: newStatus })
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Frontend: response.ok =', response.ok)
|
||||||
|
console.log('Frontend: response.status =', response.status)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Statut mis à jour',
|
||||||
|
description: 'Le statut de la commande a été mis à jour'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
if (selectedOrder?.id === orderId) {
|
||||||
|
setSelectedOrder({ ...selectedOrder, statutAtelier: newStatus as any })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
console.log('Frontend: errorData =', errorData)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: errorData.error || 'Impossible de mettre à jour le statut',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating order status:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de mettre à jour le statut',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View order details
|
||||||
|
const viewOrderDetails = async (order: WorkOrder) => {
|
||||||
|
setSelectedOrder(order)
|
||||||
|
setShowDetailDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as ready for pickup
|
||||||
|
const markAsReady = async () => {
|
||||||
|
if (!selectedOrder) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/atelier/orders/${selectedOrder.id}?XTransformPort=3000`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statutAtelier: 'PRET' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Commande prête',
|
||||||
|
description: 'La commande est marquée comme prête pour le retrait'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
setShowNotifyDialog(false)
|
||||||
|
setShowDetailDialog(false)
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de marquer la commande comme prête',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking order as ready:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de marquer la commande comme prête',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm order pickup (ready → retrieved)
|
||||||
|
const confirmRetrait = async (orderId: string) => {
|
||||||
|
if (!confirm('Confirmer que le client a récupéré ses lunettes ?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/atelier/orders/${orderId}?XTransformPort=3000`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statutAtelier: 'RETIRE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Retrait confirmé',
|
||||||
|
description: 'La commande a été marquée comme retirée'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de confirmer le retrait',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error confirming pickup:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de confirmer le retrait',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed sample data
|
||||||
|
const seedSampleData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/atelier/seed?XTransformPort=3000', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Données ajoutées',
|
||||||
|
description: 'Les données de test ont été créées avec succès'
|
||||||
|
})
|
||||||
|
loadWorkOrders()
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: error.error || 'Impossible de créer les données de test',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding data:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de créer les données de test',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkOrders()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter orders
|
||||||
|
const filteredOrders = workOrders.filter(order => {
|
||||||
|
if (statusFilter === 'ALL') return true
|
||||||
|
return order.statutAtelier === statusFilter
|
||||||
|
})
|
||||||
|
|
||||||
|
const readyOrders = workOrders.filter(order => order.statutAtelier === 'PRET')
|
||||||
|
|
||||||
|
// Get status badge
|
||||||
|
const getStatusBadge = (statut: string) => {
|
||||||
|
const variants: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||||
|
'EN_ATTENTE': {
|
||||||
|
color: 'bg-gray-500',
|
||||||
|
label: 'En attente',
|
||||||
|
icon: <Clock className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'EN_COURS': {
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
label: 'En cours',
|
||||||
|
icon: <Play className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'TERMINE': {
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
label: 'Terminé',
|
||||||
|
icon: <CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'PRET': {
|
||||||
|
color: 'bg-green-500',
|
||||||
|
label: 'Prêt',
|
||||||
|
icon: <Bell className="h-3 w-3 mr-1" />
|
||||||
|
},
|
||||||
|
'RETIRE': {
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
label: 'Retiré',
|
||||||
|
icon: <CheckCheck className="h-3 w-3 mr-1" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = variants[statut] || variants['EN_ATTENTE']
|
||||||
|
return (
|
||||||
|
<Badge className={status.color}>
|
||||||
|
{status.icon}
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format vision data
|
||||||
|
const formatVisionData = (patient?: Patient) => {
|
||||||
|
if (!patient) return null
|
||||||
|
return {
|
||||||
|
od: `Sph: ${patient.odSphere || '-'} | Cyl: ${patient.odCylindre || '-'} | Axe: ${patient.odAxe || '-'}`,
|
||||||
|
og: `Sph: ${patient.ogSphere || '-'} | Cyl: ${patient.ogCylindre || '-'} | Axe: ${patient.ogAxe || '-'}`,
|
||||||
|
addition: patient.addition ? `Add: ${patient.addition}` : null,
|
||||||
|
pd: patient.pd ? `PD: ${patient.pd}mm` : null,
|
||||||
|
hauteur: patient.hauteur ? `Haut: ${patient.hauteur}mm` : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">En attente</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'EN_ATTENTE').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">En cours</CardTitle>
|
||||||
|
<Play className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'EN_COURS').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Terminé</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-purple-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'TERMINE').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Prêt à retirer</CardTitle>
|
||||||
|
<Bell className="h-4 w-4 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'PRET').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Retiré</CardTitle>
|
||||||
|
<CheckCheck className="h-4 w-4 text-orange-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'orders' | 'ready')}>
|
||||||
|
<TabsList className="grid w-full max-w-lg grid-cols-3">
|
||||||
|
<TabsTrigger value="orders">Commandes</TabsTrigger>
|
||||||
|
<TabsTrigger value="ready">
|
||||||
|
Prêtes
|
||||||
|
{readyOrders.length > 0 && (
|
||||||
|
<Badge className="ml-2 bg-green-500">{readyOrders.length}</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">
|
||||||
|
Historique
|
||||||
|
{workOrders.filter(o => o.statutAtelier === 'RETIRE').length > 0 && (
|
||||||
|
<Badge className="ml-2 bg-orange-500">{workOrders.filter(o => o.statutAtelier === 'RETIRE').length}</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="orders" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Wrench className="h-5 w-5" />
|
||||||
|
Commandes de Montage
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Gérez les commandes de montage de lunettes
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'ALL' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('ALL')}
|
||||||
|
>
|
||||||
|
Toutes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'EN_ATTENTE' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('EN_ATTENTE')}
|
||||||
|
>
|
||||||
|
En attente
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'EN_COURS' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('EN_COURS')}
|
||||||
|
>
|
||||||
|
En cours
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'TERMINE' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('TERMINE')}
|
||||||
|
>
|
||||||
|
Terminé
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'RETIRE' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('RETIRE')}
|
||||||
|
>
|
||||||
|
Retiré
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : filteredOrders.length === 0 ? (
|
||||||
|
<div className="text-center py-8 space-y-4">
|
||||||
|
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{statusFilter === 'ALL'
|
||||||
|
? 'Aucune commande de montage en cours'
|
||||||
|
: statusFilter === 'RETIRE'
|
||||||
|
? 'Aucune commande retirée'
|
||||||
|
: `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{statusFilter === 'ALL' && workOrders.length === 0 && (
|
||||||
|
<Button onClick={seedSampleData} variant="outline">
|
||||||
|
<Wrench className="h-4 w-4 mr-2" />
|
||||||
|
Ajouter des données de test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[600px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>N° Commande</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Produits</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-medium">{order.numero}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(order.date).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.client
|
||||||
|
? `${order.client.prenom} ${order.client.nom}`
|
||||||
|
: 'Client anonyme'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{order.lignes.slice(0, 2).map((ligne) => (
|
||||||
|
<span key={ligne.id} className="text-xs">
|
||||||
|
{ligne.produit.designation}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{order.lignes.length > 2 && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
+{order.lignes.length - 2} autre(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(order.statutAtelier)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => viewOrderDetails(order)}
|
||||||
|
>
|
||||||
|
Voir détails
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ready">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5 text-green-500" />
|
||||||
|
Commandes prêtes pour retrait
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Les commandes terminées et prêtes à être remises aux clients
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{readyOrders.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Aucune commande prête pour le retrait
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[600px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>N° Commande</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Téléphone</TableHead>
|
||||||
|
<TableHead>Prêt depuis</TableHead>
|
||||||
|
<TableHead className="text-right">Montant TTC</TableHead>
|
||||||
|
<TableHead className="text-center">Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{readyOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="font-medium">{order.numero}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(order.date).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.client
|
||||||
|
? `${order.client.prenom} ${order.client.nom}`
|
||||||
|
: 'Client anonyme'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.client?.telephone || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.dateAtelier
|
||||||
|
? new Date(order.dateAtelier).toLocaleDateString('fr-FR')
|
||||||
|
: new Date(order.date).toLocaleDateString('fr-FR')
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{order.montantTTC.toFixed(2)} €
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => confirmRetrait(order.id)}
|
||||||
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-4 w-4 mr-2" />
|
||||||
|
Confirmer retrait
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* History Tab */}
|
||||||
|
<TabsContent value="history" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCheck className="h-5 w-5 text-orange-500" />
|
||||||
|
Historique des retraits
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Les commandes qui ont é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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
996
src/components/pos/POSModule.tsx
Normal file
996
src/components/pos/POSModule.tsx
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
ShoppingCart,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
CreditCard,
|
||||||
|
DollarSign,
|
||||||
|
FileText,
|
||||||
|
X,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface Produit {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
categorie: string
|
||||||
|
prixVenteTTC: number
|
||||||
|
tva: number
|
||||||
|
stock: number
|
||||||
|
marque?: string
|
||||||
|
typeMonture?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
prenom: string
|
||||||
|
email?: string
|
||||||
|
telephone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartItem {
|
||||||
|
produit: Produit
|
||||||
|
quantite: number
|
||||||
|
prixUnitaireHT: number
|
||||||
|
prixUnitaireTTC: number
|
||||||
|
remise: number
|
||||||
|
montantHT: number
|
||||||
|
montantTTC: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Paiement {
|
||||||
|
mode: 'ESPECES' | 'CARTE' | 'CHEQUE' | 'VIREMENT' | 'BON_CAISSE'
|
||||||
|
montant: number
|
||||||
|
reference?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vente {
|
||||||
|
id: string
|
||||||
|
numero: string
|
||||||
|
date: string
|
||||||
|
statut: 'INITIEE' | 'PAYEE' | 'ANNULEE' | 'REMBOURSEE'
|
||||||
|
montantHT: number
|
||||||
|
montantTVA: number
|
||||||
|
montantTTC: number
|
||||||
|
remise: number
|
||||||
|
client?: Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const calculateHTFromTTC = (ttc: number, tvaRate: number) => {
|
||||||
|
return ttc / (1 + tvaRate / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTTCFromHT = (ht: number, tvaRate: number) => {
|
||||||
|
return ht * (1 + tvaRate / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function POSModule() {
|
||||||
|
// State
|
||||||
|
const [products, setProducts] = useState<Produit[]>([])
|
||||||
|
const [filteredProducts, setFilteredProducts] = useState<Produit[]>([])
|
||||||
|
const [clients, setClients] = useState<Client[]>([])
|
||||||
|
const [cart, setCart] = useState<CartItem[]>([])
|
||||||
|
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [globalDiscount, setGlobalDiscount] = useState(0)
|
||||||
|
const [payments, setPayments] = useState<Paiement[]>([])
|
||||||
|
const [currentPayment, setCurrentPayment] = useState<Paiement>({
|
||||||
|
mode: 'ESPECES',
|
||||||
|
montant: 0
|
||||||
|
})
|
||||||
|
const [showCustomerDialog, setShowCustomerDialog] = useState(false)
|
||||||
|
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
|
||||||
|
const [showInvoiceDialog, setShowInvoiceDialog] = useState(false)
|
||||||
|
const [currentSale, setCurrentSale] = useState<Vente | null>(null)
|
||||||
|
const [newClient, setNewClient] = useState({
|
||||||
|
nom: '',
|
||||||
|
prenom: '',
|
||||||
|
email: '',
|
||||||
|
telephone: ''
|
||||||
|
})
|
||||||
|
const [activeTab, setActiveTab] = useState<'pos' | 'history'>('pos')
|
||||||
|
const [salesHistory, setSalesHistory] = useState<Vente[]>([])
|
||||||
|
|
||||||
|
// Calculations
|
||||||
|
const cartTotals = cart.reduce((acc, item) => ({
|
||||||
|
ht: acc.ht + item.montantHT,
|
||||||
|
ttc: acc.ttc + item.montantTTC
|
||||||
|
}), { ht: 0, ttc: 0 })
|
||||||
|
|
||||||
|
const totalTVA = cartTotals.ttc - cartTotals.ht
|
||||||
|
const globalDiscountAmount = (cartTotals.ttc * globalDiscount) / 100
|
||||||
|
const finalTTC = cartTotals.ttc - globalDiscountAmount
|
||||||
|
const totalPaid = payments.reduce((sum, p) => sum + p.montant, 0)
|
||||||
|
const remainingToPay = finalTTC - totalPaid
|
||||||
|
|
||||||
|
// Load functions
|
||||||
|
const loadProducts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/products')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setProducts(data)
|
||||||
|
setFilteredProducts(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading products:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de charger les produits',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadClients = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/clients')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setClients(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading clients:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSalesHistory = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/sales')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSalesHistory(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading sales history:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed sample data
|
||||||
|
const seedSampleData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/seed', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Données ajoutées',
|
||||||
|
description: 'Les données de test ont été créées avec succès'
|
||||||
|
})
|
||||||
|
loadProducts()
|
||||||
|
loadClients()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding data:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de créer les données de test',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load products on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadProducts()
|
||||||
|
loadClients()
|
||||||
|
loadSalesHistory()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Search products
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setFilteredProducts(products)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
const filtered = products.filter(p =>
|
||||||
|
p.reference.toLowerCase().includes(query) ||
|
||||||
|
p.designation.toLowerCase().includes(query) ||
|
||||||
|
p.marque?.toLowerCase().includes(query) ||
|
||||||
|
p.categorie.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
setFilteredProducts(filtered)
|
||||||
|
}, [searchQuery, products])
|
||||||
|
|
||||||
|
// Add to cart
|
||||||
|
const addToCart = (product: Produit) => {
|
||||||
|
if (product.stock <= 0) {
|
||||||
|
toast({
|
||||||
|
title: 'Stock épuisé',
|
||||||
|
description: 'Ce produit n\'est plus en stock',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCart(prevCart => {
|
||||||
|
const existingItem = prevCart.find(item => item.produit.id === product.id)
|
||||||
|
const maxQty = existingItem ? existingItem.quantite + 1 : 1
|
||||||
|
|
||||||
|
if (maxQty > product.stock) {
|
||||||
|
toast({
|
||||||
|
title: 'Stock insuffisant',
|
||||||
|
description: `Seulement ${product.stock} unités disponibles`,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return prevCart
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
const updatedItem = {
|
||||||
|
...existingItem,
|
||||||
|
quantite: maxQty,
|
||||||
|
montantHT: existingItem.prixUnitaireHT * maxQty,
|
||||||
|
montantTTC: existingItem.prixUnitaireTTC * maxQty
|
||||||
|
}
|
||||||
|
return prevCart.map(item =>
|
||||||
|
item.produit.id === product.id ? updatedItem : item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prixHT = calculateHTFromTTC(product.prixVenteTTC, product.tva)
|
||||||
|
const newItem: CartItem = {
|
||||||
|
produit: product,
|
||||||
|
quantite: 1,
|
||||||
|
prixUnitaireHT: prixHT,
|
||||||
|
prixUnitaireTTC: product.prixVenteTTC,
|
||||||
|
remise: 0,
|
||||||
|
montantHT: prixHT,
|
||||||
|
montantTTC: product.prixVenteTTC
|
||||||
|
}
|
||||||
|
return [...prevCart, newItem]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cart item quantity
|
||||||
|
const updateQuantity = (productId: string, newQuantity: number) => {
|
||||||
|
if (newQuantity < 1) return
|
||||||
|
|
||||||
|
const item = cart.find(i => i.produit.id === productId)
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
if (newQuantity > item.produit.stock) {
|
||||||
|
toast({
|
||||||
|
title: 'Stock insuffisant',
|
||||||
|
description: `Seulement ${item.produit.stock} unités disponibles`,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCart(prevCart =>
|
||||||
|
prevCart.map(item => {
|
||||||
|
if (item.produit.id === productId) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
quantite: newQuantity,
|
||||||
|
montantHT: item.prixUnitaireHT * newQuantity,
|
||||||
|
montantTTC: item.prixUnitaireTTC * newQuantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from cart
|
||||||
|
const removeFromCart = (productId: string) => {
|
||||||
|
setCart(prevCart => prevCart.filter(item => item.produit.id !== productId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cart
|
||||||
|
const clearCart = () => {
|
||||||
|
setCart([])
|
||||||
|
setSelectedClient(null)
|
||||||
|
setGlobalDiscount(0)
|
||||||
|
setPayments([])
|
||||||
|
setCurrentPayment({ mode: 'ESPECES', montant: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new client
|
||||||
|
const handleCreateClient = async () => {
|
||||||
|
if (!newClient.nom || !newClient.prenom || !newClient.telephone) {
|
||||||
|
toast({
|
||||||
|
title: 'Champs requis',
|
||||||
|
description: 'Veuillez remplir tous les champs obligatoires',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/clients', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const createdClient = await response.json()
|
||||||
|
setSelectedClient(createdClient)
|
||||||
|
setClients([...clients, createdClient])
|
||||||
|
setNewClient({ nom: '', prenom: '', email: '', telephone: '' })
|
||||||
|
setShowCustomerDialog(false)
|
||||||
|
toast({
|
||||||
|
title: 'Client créé',
|
||||||
|
description: 'Le client a été créé avec succès'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating client:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible de créer le client',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add payment
|
||||||
|
const addPayment = () => {
|
||||||
|
if (currentPayment.montant <= 0) {
|
||||||
|
toast({
|
||||||
|
title: 'Montant invalide',
|
||||||
|
description: 'Veuillez entrer un montant valide',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPaid + currentPayment.montant > finalTTC) {
|
||||||
|
toast({
|
||||||
|
title: 'Montant excessif',
|
||||||
|
description: 'Le paiement dépasse le montant dû',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPayments([...payments, currentPayment])
|
||||||
|
setCurrentPayment({ mode: 'ESPECES', montant: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove payment
|
||||||
|
const removePayment = (index: number) => {
|
||||||
|
setPayments(payments.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete sale
|
||||||
|
const completeSale = async () => {
|
||||||
|
if (cart.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: 'Panier vide',
|
||||||
|
description: 'Veuillez ajouter des produits au panier',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingToPay > 0.01) {
|
||||||
|
toast({
|
||||||
|
title: 'Paiement incomplet',
|
||||||
|
description: `Il reste ${remainingToPay.toFixed(2)} € à payer`,
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pos/sales', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId: selectedClient?.id || null,
|
||||||
|
lignes: cart.map(item => ({
|
||||||
|
produitId: item.produit.id,
|
||||||
|
quantite: item.quantite,
|
||||||
|
prixUnitaireHT: item.prixUnitaireHT,
|
||||||
|
prixUnitaireTTC: item.prixUnitaireTTC,
|
||||||
|
remise: item.remise,
|
||||||
|
montantHT: item.montantHT,
|
||||||
|
montantTTC: item.montantTTC
|
||||||
|
})),
|
||||||
|
paiements: payments,
|
||||||
|
remise: globalDiscount,
|
||||||
|
montantHT: cartTotals.ht - (globalDiscountAmount / (1 + 0.2)),
|
||||||
|
montantTVA: totalTVA,
|
||||||
|
montantTTC: finalTTC,
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const sale = await response.json()
|
||||||
|
setCurrentSale(sale)
|
||||||
|
setShowInvoiceDialog(true)
|
||||||
|
loadSalesHistory()
|
||||||
|
loadProducts()
|
||||||
|
clearCart()
|
||||||
|
toast({
|
||||||
|
title: 'Vente enregistrée',
|
||||||
|
description: `Vente ${sale.numero} complétée avec succès`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing sale:', error)
|
||||||
|
toast({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: 'Impossible d\'enregistrer la vente',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status badge
|
||||||
|
const getStatusBadge = (statut: string) => {
|
||||||
|
const variants: Record<string, { color: string; label: string }> = {
|
||||||
|
'INITIEE': { color: 'bg-yellow-500', label: 'Initiée' },
|
||||||
|
'PAYEE': { color: 'bg-green-500', label: 'Payée' },
|
||||||
|
'ANNULEE': { color: 'bg-red-500', label: 'Annulée' },
|
||||||
|
'REMBOURSEE': { color: 'bg-blue-500', label: 'Remboursée' }
|
||||||
|
}
|
||||||
|
const status = variants[statut] || variants['INITIEE']
|
||||||
|
return <Badge className={status.color}>{status.label}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'pos' | 'history')}>
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
|
<TabsTrigger value="pos">Point de Vente</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">Historique</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="pos" className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left column: Products */}
|
||||||
|
<div className="lg:col-span-1 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Search className="h-5 w-5" />
|
||||||
|
Recherche Produits
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Référence, désignation, marque..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[600px] pr-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredProducts.map(product => (
|
||||||
|
<Card
|
||||||
|
key={product.id}
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-md hover:border-primary ${
|
||||||
|
product.stock <= 0 ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => product.stock > 0 && addToCart(product)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{product.designation}</p>
|
||||||
|
<p className="text-xs text-gray-500">{product.reference}</p>
|
||||||
|
{product.marque && (
|
||||||
|
<p className="text-xs text-gray-400">{product.marque}</p>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="mt-2 text-xs">
|
||||||
|
{product.categorie}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-primary">
|
||||||
|
{product.prixVenteTTC.toFixed(2)} €
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Stock: {product.stock}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{filteredProducts.length === 0 && (
|
||||||
|
<div className="text-center py-8 space-y-4">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{products.length === 0
|
||||||
|
? 'Aucun produit dans la base de données'
|
||||||
|
: 'Aucun produit trouvé'}
|
||||||
|
</p>
|
||||||
|
{products.length === 0 && (
|
||||||
|
<Button onClick={seedSampleData} variant="outline">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Ajouter des données de test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle & Right columns: Cart and Checkout */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{/* Customer Selection */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Client
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<Select
|
||||||
|
value={selectedClient?.id || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const client = clients.find(c => c.id === value)
|
||||||
|
setSelectedClient(client || null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Sélectionner un client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients.map(client => (
|
||||||
|
<SelectItem key={client.id} value={client.id}>
|
||||||
|
{client.prenom} {client.nom} - {client.telephone}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Dialog open={showCustomerDialog} onOpenChange={setShowCustomerDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nouveau Client
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Créer un nouveau client</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Remplissez les informations du client
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Nom *</Label>
|
||||||
|
<Input
|
||||||
|
value={newClient.nom}
|
||||||
|
onChange={(e) => setNewClient({ ...newClient, nom: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Prénom *</Label>
|
||||||
|
<Input
|
||||||
|
value={newClient.prenom}
|
||||||
|
onChange={(e) => setNewClient({ ...newClient, prenom: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Téléphone *</Label>
|
||||||
|
<Input
|
||||||
|
value={newClient.telephone}
|
||||||
|
onChange={(e) => setNewClient({ ...newClient, telephone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={newClient.email}
|
||||||
|
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreateClient} className="w-full">
|
||||||
|
Créer le client
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{selectedClient && (
|
||||||
|
<Button variant="ghost" onClick={() => setSelectedClient(null)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedClient && (
|
||||||
|
<div className="mt-2 text-sm text-gray-600">
|
||||||
|
<span className="font-medium">{selectedClient.prenom} {selectedClient.nom}</span>
|
||||||
|
{selectedClient.email && <span> - {selectedClient.email}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Shopping Cart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-5 w-5" />
|
||||||
|
Panier ({cart.length} articles)
|
||||||
|
</div>
|
||||||
|
{cart.length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearCart}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{cart.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Le panier est vide
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Produit</TableHead>
|
||||||
|
<TableHead className="text-center">Qté</TableHead>
|
||||||
|
<TableHead className="text-right">Prix Unit.</TableHead>
|
||||||
|
<TableHead className="text-right">Total TTC</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{cart.map(item => (
|
||||||
|
<TableRow key={item.produit.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{item.produit.designation}</p>
|
||||||
|
<p className="text-xs text-gray-500">{item.produit.reference}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => updateQuantity(item.produit.id, item.quantite - 1)}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-8 text-center text-sm">{item.quantite}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => updateQuantity(item.produit.id, item.quantite + 1)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">
|
||||||
|
{item.prixUnitaireTTC.toFixed(2)} €
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{item.montantTTC.toFixed(2)} €
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeFromCart(item.produit.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Total HT:</span>
|
||||||
|
<span>{cartTotals.ht.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>TVA:</span>
|
||||||
|
<span>{totalTVA.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<span className="text-sm">Remise globale:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={globalDiscount}
|
||||||
|
onChange={(e) => setGlobalDiscount(Number(e.target.value))}
|
||||||
|
className="w-20 h-8 text-right"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">%</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-red-500">
|
||||||
|
-{globalDiscountAmount.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total TTC:</span>
|
||||||
|
<span className="text-primary">{finalTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5" />
|
||||||
|
Paiement
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mode de paiement</Label>
|
||||||
|
<Select
|
||||||
|
value={currentPayment.mode}
|
||||||
|
onValueChange={(value: any) =>
|
||||||
|
setCurrentPayment({ ...currentPayment, mode: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ESPECES">Espèces</SelectItem>
|
||||||
|
<SelectItem value="CARTE">Carte bancaire</SelectItem>
|
||||||
|
<SelectItem value="CHEQUE">Chèque</SelectItem>
|
||||||
|
<SelectItem value="VIREMENT">Virement</SelectItem>
|
||||||
|
<SelectItem value="BON_CAISSE">Bon de caisse</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Montant</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={currentPayment.montant || ''}
|
||||||
|
onChange={(e) => setCurrentPayment({
|
||||||
|
...currentPayment,
|
||||||
|
montant: Number(e.target.value)
|
||||||
|
})}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
<Button onClick={addPayment} disabled={cart.length === 0}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payments list */}
|
||||||
|
{payments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Paiements enregistrés</Label>
|
||||||
|
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||||
|
{payments.map((payment, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{payment.mode === 'ESPECES' && <DollarSign className="h-4 w-4 text-green-500" />}
|
||||||
|
{payment.mode === 'CARTE' && <CreditCard className="h-4 w-4 text-blue-500" />}
|
||||||
|
{payment.mode === 'CHEQUE' && <FileText className="h-4 w-4 text-purple-500" />}
|
||||||
|
{payment.mode === 'VIREMENT' && <FileText className="h-4 w-4 text-orange-500" />}
|
||||||
|
{payment.mode === 'BON_CAISSE' && <FileText className="h-4 w-4 text-cyan-500" />}
|
||||||
|
<span className="text-sm capitalize">
|
||||||
|
{payment.mode.toLowerCase().replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{payment.montant.toFixed(2)} €</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => removePayment(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-lg">
|
||||||
|
<span>Reste à payer:</span>
|
||||||
|
<span className={`font-bold ${remainingToPay <= 0.01 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{Math.max(0, remainingToPay).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={completeSale}
|
||||||
|
disabled={cart.length === 0 || remainingToPay > 0.01}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-5 w-5 mr-2" />
|
||||||
|
Valider la vente
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Sales History */}
|
||||||
|
<TabsContent value="history">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Historique des ventes</CardTitle>
|
||||||
|
<CardDescription>Les dernières ventes enregistrées</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>N° Vente</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead className="text-right">Montant TTC</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{salesHistory.map((sale) => (
|
||||||
|
<TableRow key={sale.id}>
|
||||||
|
<TableCell className="font-medium">{sale.numero}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(sale.date).toLocaleDateString('fr-FR')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sale.client
|
||||||
|
? `${sale.client.prenom} ${sale.client.nom}`
|
||||||
|
: 'Client anonyme'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(sale.statut)}</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{sale.montantTTC.toFixed(2)} €
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{salesHistory.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
|
||||||
|
Aucune vente enregistrée
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Invoice Dialog */}
|
||||||
|
<Dialog open={showInvoiceDialog} onOpenChange={setShowInvoiceDialog}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Facture {currentSale?.numero}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Vente complétée avec succès
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{currentSale && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold mb-1">OptiqueStock</p>
|
||||||
|
<p className="text-gray-600">Système de Gestion d'Optique</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold">Facture N° {currentSale.numero}</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{new Date(currentSale.date).toLocaleDateString('fr-FR')} à{' '}
|
||||||
|
{new Date(currentSale.date).toLocaleTimeString('fr-FR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentSale.client && (
|
||||||
|
<div className="bg-gray-50 p-4 rounded">
|
||||||
|
<p className="font-semibold mb-1">Client</p>
|
||||||
|
<p>{currentSale.client.prenom} {currentSale.client.nom}</p>
|
||||||
|
<p className="text-sm text-gray-600">{currentSale.client.telephone}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Total HT:</span>
|
||||||
|
<span>{currentSale.montantHT.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>TVA:</span>
|
||||||
|
<span>{currentSale.montantTVA.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
{currentSale.remise > 0 && (
|
||||||
|
<div className="flex justify-between text-red-600">
|
||||||
|
<span>Remise:</span>
|
||||||
|
<span>-{currentSale.remise.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total TTC:</span>
|
||||||
|
<span>{currentSale.montantTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
{getStatusBadge(currentSale.statut)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => setShowInvoiceDialog(false)} className="w-full">
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
src/components/products/ImageGalleryDialog.tsx
Normal file
263
src/components/products/ImageGalleryDialog.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Loader2, Upload, X as XIcon, Image as ImageIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface Fichier {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
url: string
|
||||||
|
taille: number
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageGalleryDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
produit?: any
|
||||||
|
onRefresh?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGalleryDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
produit,
|
||||||
|
onRefresh,
|
||||||
|
}: ImageGalleryDialogProps) {
|
||||||
|
const [images, setImages] = useState<Fichier[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && produit) {
|
||||||
|
fetchImages()
|
||||||
|
}
|
||||||
|
}, [open, produit])
|
||||||
|
|
||||||
|
const fetchImages = async () => {
|
||||||
|
if (!produit) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await fetch(`/api/produits/${produit.id}/images`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch images')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setImages(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching images:', error)
|
||||||
|
toast.error('Erreur lors du chargement des images')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
const file = files[0]
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Veuillez sélectionner un fichier image')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('L\'image ne doit pas dépasser 5MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadImage(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadImage = async (file: File) => {
|
||||||
|
if (!produit) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploading(true)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('produitId', produit.id)
|
||||||
|
|
||||||
|
const response = await fetch('/api/produits/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Failed to upload image')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Image téléchargée avec succès')
|
||||||
|
fetchImages()
|
||||||
|
onRefresh?.()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error uploading image:', error)
|
||||||
|
toast.error(error.message || 'Erreur lors du téléchargement de l\'image')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteImage = async (imageId: string) => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cette image ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fichiers/${imageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete image')
|
||||||
|
|
||||||
|
toast.success('Image supprimée avec succès')
|
||||||
|
fetchImages()
|
||||||
|
onRefresh?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting image:', error)
|
||||||
|
toast.error('Erreur lors de la suppression de l\'image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!produit) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Galerie d'Images</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Gérez les images du produit: {produit.designation}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Upload Section */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Ajouter une image</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Formats acceptés: JPG, PNG, WebP (max 5MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
id="image-upload"
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="image-upload">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<span className="cursor-pointer">
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Téléchargement...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Télécharger
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Images Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<ImageIcon className="h-12 w-12 text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-500">Aucune image pour ce produit</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Téléchargez des images pour illustrer le produit
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{images.map((image) => (
|
||||||
|
<Card key={image.id} className="overflow-hidden group">
|
||||||
|
<div className="relative aspect-square">
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.nom}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteImage(image.id)}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<p className="text-sm font-medium truncate">{image.nom}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{formatFileSize(image.taille)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{image.mimeType.split('/')[1].toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
681
src/components/products/ProduitFormDialog.tsx
Normal file
681
src/components/products/ProduitFormDialog.tsx
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Loader2, Save } from 'lucide-react'
|
||||||
|
|
||||||
|
const produitFormSchema = z.object({
|
||||||
|
reference: z.string().min(1, 'La référence est requise'),
|
||||||
|
designation: z.string().min(1, 'La désignation est requise'),
|
||||||
|
categorie: z.enum(['MONTURE', 'VERRE', 'LENTILLE', 'ACCESSOIRE']),
|
||||||
|
fournisseurId: z.string().optional(),
|
||||||
|
prixAchatHT: z.number().min(0, 'Le prix d\'achat doit être positif'),
|
||||||
|
prixVenteTTC: z.number().min(0, 'Le prix de vente doit être positif'),
|
||||||
|
tva: z.number().min(0).max(100),
|
||||||
|
stock: z.number().int().min(0),
|
||||||
|
stockMin: z.number().int().min(0),
|
||||||
|
emplacement: z.string().optional(),
|
||||||
|
marque: z.string().optional(),
|
||||||
|
typeMonture: z.enum(['COMPLET', 'NATUREL', 'CERCLAGE']).optional(),
|
||||||
|
typeVerre: z.enum(['SIMPLE', 'BIFOCAL', 'PROGRESSIF']).optional(),
|
||||||
|
indice: z.number().optional(),
|
||||||
|
materiau: z.string().optional(),
|
||||||
|
couleur: z.string().optional(),
|
||||||
|
dimensions: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
codeBarre: z.string().optional(),
|
||||||
|
actif: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProduitFormValues = z.infer<typeof produitFormSchema>
|
||||||
|
|
||||||
|
export interface Fournisseur {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProduitFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
produit?: any
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProduitFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
produit,
|
||||||
|
onSuccess,
|
||||||
|
}: ProduitFormDialogProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [fournisseurs, setFournisseurs] = useState<Fournisseur[]>([])
|
||||||
|
const [loadingFournisseurs, setLoadingFournisseurs] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<ProduitFormValues>({
|
||||||
|
resolver: zodResolver(produitFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
reference: '',
|
||||||
|
designation: '',
|
||||||
|
categorie: 'MONTURE',
|
||||||
|
fournisseurId: '',
|
||||||
|
prixAchatHT: 0,
|
||||||
|
prixVenteTTC: 0,
|
||||||
|
tva: 20,
|
||||||
|
stock: 0,
|
||||||
|
stockMin: 5,
|
||||||
|
emplacement: '',
|
||||||
|
marque: '',
|
||||||
|
typeMonture: undefined,
|
||||||
|
typeVerre: undefined,
|
||||||
|
indice: undefined,
|
||||||
|
materiau: '',
|
||||||
|
couleur: '',
|
||||||
|
dimensions: '',
|
||||||
|
description: '',
|
||||||
|
codeBarre: '',
|
||||||
|
actif: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const categorie = form.watch('categorie')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchFournisseurs()
|
||||||
|
if (produit) {
|
||||||
|
form.reset({
|
||||||
|
reference: produit.reference || '',
|
||||||
|
designation: produit.designation || '',
|
||||||
|
categorie: produit.categorie || 'MONTURE',
|
||||||
|
fournisseurId: produit.fournisseurId || '',
|
||||||
|
prixAchatHT: produit.prixAchatHT || 0,
|
||||||
|
prixVenteTTC: produit.prixVenteTTC || 0,
|
||||||
|
tva: produit.tva || 20,
|
||||||
|
stock: produit.stock || 0,
|
||||||
|
stockMin: produit.stockMin || 5,
|
||||||
|
emplacement: produit.emplacement || '',
|
||||||
|
marque: produit.marque || '',
|
||||||
|
typeMonture: produit.typeMonture || undefined,
|
||||||
|
typeVerre: produit.typeVerre || undefined,
|
||||||
|
indice: produit.indice || undefined,
|
||||||
|
materiau: produit.materiau || '',
|
||||||
|
couleur: produit.couleur || '',
|
||||||
|
dimensions: produit.dimensions || '',
|
||||||
|
description: produit.description || '',
|
||||||
|
codeBarre: produit.codeBarre || '',
|
||||||
|
actif: produit.actif !== undefined ? produit.actif : true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
form.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, produit, form])
|
||||||
|
|
||||||
|
const fetchFournisseurs = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingFournisseurs(true)
|
||||||
|
const response = await fetch('/api/fournisseurs')
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch suppliers')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setFournisseurs(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching fournisseurs:', error)
|
||||||
|
} finally {
|
||||||
|
setLoadingFournisseurs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (data: ProduitFormValues) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const url = produit
|
||||||
|
? `/api/produits/${produit.id}`
|
||||||
|
: '/api/produits'
|
||||||
|
|
||||||
|
const method = produit ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Failed to save product')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(produit ? 'Produit mis à jour avec succès' : 'Produit créé avec succès')
|
||||||
|
onOpenChange(false)
|
||||||
|
onSuccess()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving produit:', error)
|
||||||
|
toast.error(error.message || 'Erreur lors de l\'enregistrement du produit')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{produit ? 'Modifier le Produit' : 'Nouveau Produit'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{produit
|
||||||
|
? 'Modifiez les informations du produit existant'
|
||||||
|
: 'Remplissez les informations pour créer un nouveau produit'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<Tabs defaultValue="general" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="general">Général</TabsTrigger>
|
||||||
|
<TabsTrigger value="pricing">Tarification</TabsTrigger>
|
||||||
|
<TabsTrigger value="stock">Stock</TabsTrigger>
|
||||||
|
<TabsTrigger value="details">Détails</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="general" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="reference"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Référence *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EX: MNT-001" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="designation"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Désignation *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Nom du produit" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categorie"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Catégorie *</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner une catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="MONTURE">Monture</SelectItem>
|
||||||
|
<SelectItem value="VERRE">Verre</SelectItem>
|
||||||
|
<SelectItem value="LENTILLE">Lentille</SelectItem>
|
||||||
|
<SelectItem value="ACCESSOIRE">Accessoire</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="fournisseurId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Fournisseur</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner un fournisseur" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Aucun fournisseur</SelectItem>
|
||||||
|
{loadingFournisseurs ? (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
Chargement...
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
fournisseurs.map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.nom}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="marque"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Marque</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Marque du produit" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codeBarre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Code-barres</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Code-barres (EAN13)" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Description détaillée du produit..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="actif"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">Produit actif</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Ce produit sera visible et disponible à la vente
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="pricing" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prixAchatHT"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Prix Achat HT (€) *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prixVenteTTC"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Prix Vente TTC (€) *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tva"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>TVA (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Taux de TVA applicable</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.watch('prixAchatHT') > 0 && form.watch('prixVenteTTC') > 0 && (
|
||||||
|
<div className="p-4 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm font-medium">Marge estimée:</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(
|
||||||
|
form.watch('prixVenteTTC') /
|
||||||
|
(1 + form.watch('tva') / 100) -
|
||||||
|
form.watch('prixAchatHT')
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{(
|
||||||
|
((form.watch('prixVenteTTC') /
|
||||||
|
(1 + form.watch('tva') / 100) -
|
||||||
|
form.watch('prixAchatHT')) /
|
||||||
|
form.watch('prixAchatHT')) *
|
||||||
|
100
|
||||||
|
).toFixed(1)}
|
||||||
|
{'% de marge'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="stock" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="stock"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Stock actuel *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="stockMin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Stock minimum *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Alerte quand le stock est inférieur
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emplacement"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Emplacement</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EX: Rayon A, Étagère 2" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="details" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{categorie === 'MONTURE' && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="typeMonture"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Type de monture</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="COMPLET">Complet</SelectItem>
|
||||||
|
<SelectItem value="NATUREL">Naturel</SelectItem>
|
||||||
|
<SelectItem value="CERCLAGE">Cerclage</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dimensions"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Dimensions</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EX: 54-18-140" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Largeur-Pont-Branches (mm)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categorie === 'VERRE' && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="typeVerre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Type de verre</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="SIMPLE">Simple foyer</SelectItem>
|
||||||
|
<SelectItem value="BIFOCAL">Bifocal</SelectItem>
|
||||||
|
<SelectItem value="PROGRESSIF">Progressif</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="indice"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Indice de réfraction</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="EX: 1.5, 1.6, 1.67"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="materiau"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Matériau</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EX: Acétate, Métal, Titane" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="couleur"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Couleur</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="EX: Noir, Rouge, Bleu marine" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Enregistrement...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Enregistrer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
492
src/components/products/ProduitListe.tsx
Normal file
492
src/components/products/ProduitListe.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Image as ImageIcon,
|
||||||
|
QrCode,
|
||||||
|
AlertTriangle,
|
||||||
|
Package,
|
||||||
|
Filter,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ProduitFormDialog } from './ProduitFormDialog'
|
||||||
|
import { QRCodeDialog } from './QRCodeDialog'
|
||||||
|
import { ImageGalleryDialog } from './ImageGalleryDialog'
|
||||||
|
|
||||||
|
export type CategorieProduit = 'MONTURE' | 'VERRE' | 'LENTILLE' | 'ACCESSOIRE'
|
||||||
|
|
||||||
|
export interface Produit {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
categorie: CategorieProduit
|
||||||
|
fournisseurId?: string | null
|
||||||
|
fournisseurNom?: string | null
|
||||||
|
prixAchatHT: number
|
||||||
|
prixVenteTTC: number
|
||||||
|
tva: number
|
||||||
|
stock: number
|
||||||
|
stockMin: number
|
||||||
|
emplacement?: string | null
|
||||||
|
marque?: string | null
|
||||||
|
typeMonture?: string | null
|
||||||
|
typeVerre?: string | null
|
||||||
|
indice?: number | null
|
||||||
|
materiau?: string | null
|
||||||
|
couleur?: string | null
|
||||||
|
dimensions?: string | null
|
||||||
|
description?: string | null
|
||||||
|
codeBarre?: string | null
|
||||||
|
actif: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
images?: Fichier[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Fichier {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
url: string
|
||||||
|
taille: number
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProduitFilters {
|
||||||
|
search: string
|
||||||
|
categorie: string
|
||||||
|
stockStatus: string
|
||||||
|
actif: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProduitListe() {
|
||||||
|
const [produits, setProduits] = useState<Produit[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedProduit, setSelectedProduit] = useState<Produit | null>(null)
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||||
|
const [isQrOpen, setIsQrOpen] = useState(false)
|
||||||
|
const [isImageOpen, setIsImageOpen] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<ProduitFilters>({
|
||||||
|
search: '',
|
||||||
|
categorie: 'all',
|
||||||
|
stockStatus: 'all',
|
||||||
|
actif: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchProduits = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.search) params.append('search', filters.search)
|
||||||
|
if (filters.categorie !== 'all') params.append('categorie', filters.categorie)
|
||||||
|
if (filters.stockStatus !== 'all') params.append('stockStatus', filters.stockStatus)
|
||||||
|
if (filters.actif !== 'all') params.append('actif', filters.actif === 'true' ? 'true' : 'false')
|
||||||
|
|
||||||
|
const response = await fetch(`/api/produits?${params.toString()}`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch products')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setProduits(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching produits:', error)
|
||||||
|
toast.error('Erreur lors du chargement des produits')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProduits()
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Êtes-vous sûr de vouloir supprimer ce produit ?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/produits/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete product')
|
||||||
|
|
||||||
|
toast.success('Produit supprimé avec succès')
|
||||||
|
fetchProduits()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting produit:', error)
|
||||||
|
toast.error('Erreur lors de la suppression du produit')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleActif = async (id: string, currentStatus: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/produits/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ actif: !currentStatus }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update product')
|
||||||
|
|
||||||
|
toast.success('Statut du produit mis à jour')
|
||||||
|
fetchProduits()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating produit:', error)
|
||||||
|
toast.error('Erreur lors de la mise à jour du produit')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStockStatus = (produit: Produit) => {
|
||||||
|
if (produit.stock <= 0) return { label: 'Rupture', variant: 'destructive' as const }
|
||||||
|
if (produit.stock < produit.stockMin) return { label: 'Stock bas', variant: 'destructive' as const }
|
||||||
|
if (produit.stock < produit.stockMin * 2) return { label: 'Stock faible', variant: 'default' as const }
|
||||||
|
return { label: 'En stock', variant: 'secondary' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategorieBadgeColor = (categorie: string) => {
|
||||||
|
switch (categorie) {
|
||||||
|
case 'MONTURE': return 'bg-blue-500'
|
||||||
|
case 'VERRE': return 'bg-green-500'
|
||||||
|
case 'LENTILLE': return 'bg-purple-500'
|
||||||
|
case 'ACCESSOIRE': return 'bg-orange-500'
|
||||||
|
default: return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = filters.search || filters.categorie !== 'all' || filters.stockStatus !== 'all' || filters.actif !== 'all'
|
||||||
|
|
||||||
|
const filteredProduits = produits
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Gestion des Produits</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{filteredProduits.length} produit{filteredProduits.length !== 1 ? 's' : ''} dans le catalogue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setSelectedProduit(null); setIsFormOpen(true); }} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nouveau Produit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Total Produits</p>
|
||||||
|
<p className="text-2xl font-bold">{produits.length}</p>
|
||||||
|
</div>
|
||||||
|
<Package className="h-8 w-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Stock Bas</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">
|
||||||
|
{produits.filter(p => p.stock < p.stockMin).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AlertTriangle className="h-8 w-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Valeur Stock HT</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
|
||||||
|
produits.reduce((acc, p) => acc + (p.prixAchatHT * p.stock), 0)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center">
|
||||||
|
<span className="text-green-600 font-bold text-sm">€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Produits Actifs</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{produits.filter(p => p.actif).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`h-8 w-8 rounded-full ${produits.filter(p => p.actif).length > 0 ? 'bg-green-100' : 'bg-gray-100'} flex items-center justify-center`}>
|
||||||
|
<span className={`${produits.filter(p => p.actif).length > 0 ? 'text-green-600' : 'text-gray-400'} font-bold text-sm`}>
|
||||||
|
{produits.filter(p => p.actif).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
Filtres
|
||||||
|
</CardTitle>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilters({ search: '', categorie: 'all', stockStatus: 'all', actif: 'all' })}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={filters.categorie} onValueChange={(value) => setFilters({ ...filters, categorie: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Catégorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toutes les catégories</SelectItem>
|
||||||
|
<SelectItem value="MONTURE">Montures</SelectItem>
|
||||||
|
<SelectItem value="VERRE">Verres</SelectItem>
|
||||||
|
<SelectItem value="LENTILLE">Lentilles</SelectItem>
|
||||||
|
<SelectItem value="ACCESSOIRE">Accessoires</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filters.stockStatus} onValueChange={(value) => setFilters({ ...filters, stockStatus: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="État du stock" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous les états</SelectItem>
|
||||||
|
<SelectItem value="low">Stock bas</SelectItem>
|
||||||
|
<SelectItem value="ok">En stock</SelectItem>
|
||||||
|
<SelectItem value="out">Rupture</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filters.actif} onValueChange={(value) => setFilters({ ...filters, actif: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Statut" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||||
|
<SelectItem value="true">Actifs</SelectItem>
|
||||||
|
<SelectItem value="false">Inactifs</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Products Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Référence</TableHead>
|
||||||
|
<TableHead>Désignation</TableHead>
|
||||||
|
<TableHead>Catégorie</TableHead>
|
||||||
|
<TableHead>Marque</TableHead>
|
||||||
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
|
<TableHead className="text-right">Prix Achat HT</TableHead>
|
||||||
|
<TableHead className="text-right">Prix Vente TTC</TableHead>
|
||||||
|
<TableHead>Emplacement</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead className="text-center">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-center py-8">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredProduits.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-center py-8 text-gray-500">
|
||||||
|
Aucun produit trouvé
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredProduits.map((produit) => {
|
||||||
|
const stockStatus = getStockStatus(produit)
|
||||||
|
return (
|
||||||
|
<TableRow key={produit.id} className={!produit.actif ? 'opacity-50' : ''}>
|
||||||
|
<TableCell className="font-medium">{produit.reference}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{produit.designation}</div>
|
||||||
|
{produit.codeBarre && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
Code-barres: {produit.codeBarre}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`${getCategorieBadgeColor(produit.categorie)} text-white`}>
|
||||||
|
{produit.categorie}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{produit.marque || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<span className={`font-semibold ${produit.stock < produit.stockMin ? 'text-red-600' : ''}`}>
|
||||||
|
{produit.stock}
|
||||||
|
</span>
|
||||||
|
{produit.stock < produit.stockMin && (
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Min: {produit.stockMin}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(produit.prixAchatHT)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(produit.prixVenteTTC)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{produit.emplacement || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={stockStatus.variant}>
|
||||||
|
{stockStatus.label}
|
||||||
|
</Badge>
|
||||||
|
{!produit.actif && (
|
||||||
|
<Badge variant="outline" className="ml-1 mt-1">
|
||||||
|
Inactif
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSelectedProduit(produit); setIsFormOpen(true); }}
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSelectedProduit(produit); setIsQrOpen(true); }}
|
||||||
|
title="QR Code"
|
||||||
|
>
|
||||||
|
<QrCode className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setSelectedProduit(produit); setIsImageOpen(true); }}
|
||||||
|
title="Images"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggleActif(produit.id, produit.actif)}
|
||||||
|
title={produit.actif ? "Désactiver" : "Activer"}
|
||||||
|
>
|
||||||
|
{produit.actif ? (
|
||||||
|
<X className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4 rounded-full bg-green-500" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(produit.id)}
|
||||||
|
title="Supprimer"
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<ProduitFormDialog
|
||||||
|
open={isFormOpen}
|
||||||
|
onOpenChange={setIsFormOpen}
|
||||||
|
produit={selectedProduit}
|
||||||
|
onSuccess={fetchProduits}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QRCodeDialog
|
||||||
|
open={isQrOpen}
|
||||||
|
onOpenChange={setIsQrOpen}
|
||||||
|
produit={selectedProduit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageGalleryDialog
|
||||||
|
open={isImageOpen}
|
||||||
|
onOpenChange={setIsImageOpen}
|
||||||
|
produit={selectedProduit}
|
||||||
|
onRefresh={fetchProduits}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
src/components/products/QRCodeDialog.tsx
Normal file
196
src/components/products/QRCodeDialog.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Loader2, Download, Printer, CheckCircle } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { generateProductQRCode } from '@/lib/qr-code'
|
||||||
|
|
||||||
|
interface QRCodeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
produit?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QRCodeDialog({ open, onOpenChange, produit }: QRCodeDialogProps) {
|
||||||
|
const [qrCodeData, setQrCodeData] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && produit) {
|
||||||
|
generateQR()
|
||||||
|
}
|
||||||
|
}, [open, produit])
|
||||||
|
|
||||||
|
const generateQR = async () => {
|
||||||
|
if (!produit) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const qrData = await generateProductQRCode(produit.reference, produit.designation)
|
||||||
|
setQrCodeData(qrData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating QR code:', error)
|
||||||
|
toast.error('Erreur lors de la génération du QR code')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!qrCodeData) return
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = qrCodeData
|
||||||
|
link.download = `qr-${produit?.reference || 'produit'}.png`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
toast.success('QR code téléchargé')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
if (!qrCodeData) return
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (!printWindow) return
|
||||||
|
|
||||||
|
const printContent = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>QR Code - ${produit?.reference}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.qr-label {
|
||||||
|
border: 2px solid #000;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
.qr-label img {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
.qr-label h2 {
|
||||||
|
margin: 10px 0 5px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.qr-label p {
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.qr-label .reference {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="qr-label">
|
||||||
|
<img src="${qrCodeData}" alt="QR Code" />
|
||||||
|
<h2>${produit?.designation || 'Produit'}</h2>
|
||||||
|
<p class="reference">${produit?.reference || ''}</p>
|
||||||
|
${produit?.codeBarre ? `<p>Code-barres: ${produit.codeBarre}</p>` : ''}
|
||||||
|
${produit?.prixVenteTTC ? `<p>Prix: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(produit.prixVenteTTC)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
printWindow.document.write(printContent)
|
||||||
|
printWindow.document.close()
|
||||||
|
printWindow.focus()
|
||||||
|
printWindow.print()
|
||||||
|
toast.success('Impression lancée')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!produit) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>QR Code du Produit</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Générez et imprimez le QR code pour l'étiquetage
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
{qrCodeData && (
|
||||||
|
<img
|
||||||
|
src={qrCodeData}
|
||||||
|
alt="QR Code"
|
||||||
|
className="border-2 border-gray-200 rounded-lg p-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="font-semibold text-lg">{produit.designation}</p>
|
||||||
|
<p className="text-sm text-gray-500">Réf: {produit.reference}</p>
|
||||||
|
{produit.codeBarre && (
|
||||||
|
<p className="text-xs text-gray-400">EAN: {produit.codeBarre}</p>
|
||||||
|
)}
|
||||||
|
{produit.prixVenteTTC && (
|
||||||
|
<p className="text-lg font-bold text-green-600 mt-2">
|
||||||
|
{new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(produit.prixVenteTTC)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!qrCodeData || loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Télécharger
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={!qrCodeData || loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
|
Imprimer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
1041
src/components/purchases/PurchaseModule.tsx
Normal file
1041
src/components/purchases/PurchaseModule.tsx
Normal file
File diff suppressed because it is too large
Load Diff
658
src/components/reports/ReportsModule.tsx
Normal file
658
src/components/reports/ReportsModule.tsx
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
|
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Legend, ResponsiveContainer } from 'recharts'
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
ArrowRight
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { format, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfYear, endOfYear, subDays } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface KPICardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
change?: number
|
||||||
|
icon: React.ReactNode
|
||||||
|
trend?: 'up' | 'down' | 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
totalSales: {
|
||||||
|
today: number
|
||||||
|
week: number
|
||||||
|
month: number
|
||||||
|
year: number
|
||||||
|
}
|
||||||
|
revenue: {
|
||||||
|
htToday: number
|
||||||
|
ttcToday: number
|
||||||
|
htMonth: number
|
||||||
|
ttcMonth: number
|
||||||
|
}
|
||||||
|
totalClients: number
|
||||||
|
topProducts: {
|
||||||
|
designation: string
|
||||||
|
quantity: number
|
||||||
|
revenue: number
|
||||||
|
}[]
|
||||||
|
lowStockItems: {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
stock: number
|
||||||
|
stockMin: number
|
||||||
|
}[]
|
||||||
|
pendingWorkshopOrders: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesReportData {
|
||||||
|
salesByDate: {
|
||||||
|
date: string
|
||||||
|
sales: number
|
||||||
|
revenue: number
|
||||||
|
}[]
|
||||||
|
salesByCategory: {
|
||||||
|
category: string
|
||||||
|
count: number
|
||||||
|
revenue: number
|
||||||
|
}[]
|
||||||
|
salesByEmployee: {
|
||||||
|
employee: string
|
||||||
|
sales: number
|
||||||
|
revenue: number
|
||||||
|
}[]
|
||||||
|
salesByPaymentMethod: {
|
||||||
|
method: string
|
||||||
|
count: number
|
||||||
|
amount: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryReportData {
|
||||||
|
stockValuation: {
|
||||||
|
totalValue: number
|
||||||
|
byCategory: {
|
||||||
|
category: string
|
||||||
|
value: number
|
||||||
|
count: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
lowStockItems: {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
category: string
|
||||||
|
stock: number
|
||||||
|
stockMin: number
|
||||||
|
value: number
|
||||||
|
}[]
|
||||||
|
categoryBreakdown: {
|
||||||
|
category: string
|
||||||
|
totalProducts: number
|
||||||
|
activeProducts: number
|
||||||
|
totalStock: number
|
||||||
|
stockValue: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI Card Component
|
||||||
|
function KPICard({ title, value, change, icon, trend = 'neutral' }: KPICardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
|
<div className="text-muted-foreground">{icon}</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{typeof value === 'number' ? value.toLocaleString() : value}</div>
|
||||||
|
{change !== undefined && (
|
||||||
|
<p className={`text-xs flex items-center gap-1 mt-1 ${trend === 'up' ? 'text-green-600' : trend === 'down' ? 'text-red-600' : 'text-muted-foreground'}`}>
|
||||||
|
{trend === 'up' && <TrendingUp className="h-3 w-3" />}
|
||||||
|
{trend === 'down' && <TrendingDown className="h-3 w-3" />}
|
||||||
|
{change > 0 ? '+' : ''}{change.toFixed(1)}% par rapport à hier
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart colors
|
||||||
|
const CHART_COLORS = {
|
||||||
|
sales: '#10b981',
|
||||||
|
revenue: '#3b82f6',
|
||||||
|
monture: '#8b5cf6',
|
||||||
|
verre: '#06b6d4',
|
||||||
|
lentille: '#f59e0b',
|
||||||
|
accessoire: '#ec4899',
|
||||||
|
especes: '#10b981',
|
||||||
|
carte: '#3b82f6',
|
||||||
|
cheque: '#f59e0b',
|
||||||
|
virement: '#8b5cf6',
|
||||||
|
bonCaisse: '#ec4899',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsModule() {
|
||||||
|
const [activeTab, setActiveTab] = useState('dashboard')
|
||||||
|
const [dateRange, setDateRange] = useState<'today' | 'week' | 'month' | 'year' | 'custom'>('month')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
|
||||||
|
const [salesData, setSalesData] = useState<SalesReportData | null>(null)
|
||||||
|
const [inventoryData, setInventoryData] = useState<InventoryReportData | null>(null)
|
||||||
|
|
||||||
|
// Fetch dashboard data
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await fetch('/api/reports/dashboard')
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch dashboard data')
|
||||||
|
const data = await response.json()
|
||||||
|
setDashboardData(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard data:', error)
|
||||||
|
toast.error('Erreur lors du chargement des données du tableau de bord')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch sales report data
|
||||||
|
const fetchSalesData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/reports/sales?range=${dateRange}`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch sales data')
|
||||||
|
const data = await response.json()
|
||||||
|
setSalesData(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching sales data:', error)
|
||||||
|
toast.error('Erreur lors du chargement des données de ventes')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch inventory report data
|
||||||
|
const fetchInventoryData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/reports/inventory')
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch inventory data')
|
||||||
|
const data = await response.json()
|
||||||
|
setInventoryData(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching inventory data:', error)
|
||||||
|
toast.error('Erreur lors du chargement des données de stock')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export data to CSV
|
||||||
|
const exportToCSV = async (type: 'sales' | 'inventory' | 'lowStock') => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/reports/export/${type}?range=${dateRange}`)
|
||||||
|
if (!response.ok) throw new Error('Failed to export data')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${type}_${format(new Date(), 'yyyy-MM-dd')}.csv`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
toast.success('Export CSV réussi')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting data:', error)
|
||||||
|
toast.error('Erreur lors de l\'export des données')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data based on active tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'dashboard') {
|
||||||
|
fetchDashboardData()
|
||||||
|
} else if (activeTab === 'sales') {
|
||||||
|
fetchSalesData()
|
||||||
|
} else if (activeTab === 'inventory') {
|
||||||
|
fetchInventoryData()
|
||||||
|
}
|
||||||
|
}, [activeTab, dateRange])
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
sales: { label: 'Ventes', color: CHART_COLORS.sales },
|
||||||
|
revenue: { label: 'Chiffre d\'affaires', color: CHART_COLORS.revenue },
|
||||||
|
monture: { label: 'Montures', color: CHART_COLORS.monture },
|
||||||
|
verre: { label: 'Verres', color: CHART_COLORS.verre },
|
||||||
|
lentille: { label: 'Lentilles', color: CHART_COLORS.lentille },
|
||||||
|
accessoire: { label: 'Accessoires', color: CHART_COLORS.accessoire },
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Rapports</h1>
|
||||||
|
<p className="text-muted-foreground">Tableau de bord et statistiques de votre magasin</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={dateRange} onValueChange={(value: any) => setDateRange(value)}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="today">Aujourd'hui</SelectItem>
|
||||||
|
<SelectItem value="week">Cette semaine</SelectItem>
|
||||||
|
<SelectItem value="month">Ce mois</SelectItem>
|
||||||
|
<SelectItem value="year">Cette année</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{activeTab !== 'dashboard' && (
|
||||||
|
<Button onClick={() => exportToCSV(activeTab === 'sales' ? 'sales' : activeTab === 'inventory' ? 'inventory' : 'lowStock')}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Exporter CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="dashboard">Tableau de bord</TabsTrigger>
|
||||||
|
<TabsTrigger value="sales">Rapports de ventes</TabsTrigger>
|
||||||
|
<TabsTrigger value="inventory">Rapports de stock</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Dashboard Tab */}
|
||||||
|
<TabsContent value="dashboard" className="space-y-4">
|
||||||
|
{loading && !dashboardData ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KPICard
|
||||||
|
title="Ventes du jour"
|
||||||
|
value={dashboardData?.totalSales.today || 0}
|
||||||
|
icon={<Package className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="CA du jour (TTC)"
|
||||||
|
value={`${dashboardData?.revenue.ttcToday.toFixed(2) || 0} €`}
|
||||||
|
icon={<DollarSign className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="Clients total"
|
||||||
|
value={dashboardData?.totalClients || 0}
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="Commandes atelier en attente"
|
||||||
|
value={dashboardData?.pendingWorkshopOrders || 0}
|
||||||
|
icon={<AlertTriangle className="h-4 w-4" />}
|
||||||
|
trend={dashboardData?.pendingWorkshopOrders && dashboardData.pendingWorkshopOrders > 0 ? 'down' : 'neutral'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional KPIs */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KPICard
|
||||||
|
title="Ventes de la semaine"
|
||||||
|
value={dashboardData?.totalSales.week || 0}
|
||||||
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="CA de la semaine (TTC)"
|
||||||
|
value={`${dashboardData?.revenue.htMonth.toFixed(2) || 0} €`}
|
||||||
|
icon={<DollarSign className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="Ventes du mois"
|
||||||
|
value={dashboardData?.totalSales.month || 0}
|
||||||
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="CA du mois (TTC)"
|
||||||
|
value={`${dashboardData?.revenue.ttcMonth.toFixed(2) || 0} €`}
|
||||||
|
icon={<DollarSign className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Products and Low Stock */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* Top Selling Products */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top 5 des produits vendus</CardTitle>
|
||||||
|
<CardDescription>Les produits les plus populaires ce mois</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dashboardData?.topProducts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">Aucune vente ce mois</p>
|
||||||
|
) : (
|
||||||
|
dashboardData?.topProducts.map((product, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{product.designation}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{product.quantity} vendus</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold">{product.revenue.toFixed(2)} €</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Low Stock Alerts */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Alertes de stock
|
||||||
|
<Badge variant="destructive">{dashboardData?.lowStockItems.length || 0}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Produits en dessous du stock minimum</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{dashboardData?.lowStockItems.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">Aucune alerte de stock</p>
|
||||||
|
) : (
|
||||||
|
dashboardData?.lowStockItems.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-center justify-between p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-destructive">{item.designation}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{item.reference}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="destructive" className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{item.stock} / {item.stockMin}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Sales Reports Tab */}
|
||||||
|
<TabsContent value="sales" className="space-y-4">
|
||||||
|
{loading && !salesData ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Sales by Date Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Évolution des ventes</CardTitle>
|
||||||
|
<CardDescription>Ventes et chiffre d'affaires par période</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-[350px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={salesData?.salesByDate || []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis yAxisId="left" />
|
||||||
|
<YAxis yAxisId="right" orientation="right" />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Legend />
|
||||||
|
<Bar yAxisId="left" dataKey="sales" fill={CHART_COLORS.sales} name="Ventes" />
|
||||||
|
<Bar yAxisId="right" dataKey="revenue" fill={CHART_COLORS.revenue} name="CA (€)" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* Sales by Category Pie Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ventes par catégorie</CardTitle>
|
||||||
|
<CardDescription>Répartition des ventes par type de produit</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={salesData?.salesByCategory || []}
|
||||||
|
dataKey="revenue"
|
||||||
|
nameKey="category"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ category, percent }) => `${category} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
{salesData?.salesByCategory.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={Object.values(CHART_COLORS)[index % Object.values(CHART_COLORS).length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sales by Payment Method */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mode de paiement</CardTitle>
|
||||||
|
<CardDescription>Répartition des paiements</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={salesData?.salesByPaymentMethod || []} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" />
|
||||||
|
<YAxis dataKey="method" type="category" width={100} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey="amount" fill={CHART_COLORS.revenue} name="Montant (€)" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sales by Employee Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ventes par employé</CardTitle>
|
||||||
|
<CardDescription>Performance de l'équipe</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Employé</TableHead>
|
||||||
|
<TableHead className="text-right">Nombre de ventes</TableHead>
|
||||||
|
<TableHead className="text-right">Chiffre d'affaires</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{salesData?.salesByEmployee.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||||
|
Aucune donnée disponible
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
salesData?.salesByEmployee.map((employee, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="font-medium">{employee.employee}</TableCell>
|
||||||
|
<TableCell className="text-right">{employee.sales}</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">{employee.revenue.toFixed(2)} €</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Inventory Reports Tab */}
|
||||||
|
<TabsContent value="inventory" className="space-y-4">
|
||||||
|
{loading && !inventoryData ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Stock Valuation */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Valorisation du stock</CardTitle>
|
||||||
|
<CardDescription>Valeur totale du stock par catégorie</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Valeur totale du stock</p>
|
||||||
|
<p className="text-3xl font-bold">{inventoryData?.stockValuation.totalValue.toFixed(2)} € HT</p>
|
||||||
|
</div>
|
||||||
|
<ChartContainer config={chartConfig} className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={inventoryData?.stockValuation.byCategory || []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="category" />
|
||||||
|
<YAxis />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey="value" fill={CHART_COLORS.monture} name="Valeur (€ HT)" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* Category Breakdown Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Répartition par catégorie</CardTitle>
|
||||||
|
<CardDescription>Détails du stock par type de produit</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Catégorie</TableHead>
|
||||||
|
<TableHead className="text-right">Produits</TableHead>
|
||||||
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
|
<TableHead className="text-right">Valeur</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{inventoryData?.categoryBreakdown.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
Aucune donnée disponible
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
inventoryData?.categoryBreakdown.map((cat) => (
|
||||||
|
<TableRow key={cat.category}>
|
||||||
|
<TableCell className="font-medium">{cat.category}</TableCell>
|
||||||
|
<TableCell className="text-right">{cat.activeProducts} / {cat.totalProducts}</TableCell>
|
||||||
|
<TableCell className="text-right">{cat.totalStock}</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">{cat.stockValue.toFixed(2)} €</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Low Stock Details */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Produits en stock faible
|
||||||
|
<Badge variant="destructive">{inventoryData?.lowStockItems.length || 0}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Produits nécessitant un réapprovisionnement</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{inventoryData?.lowStockItems.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">Tous les stocks sont OK</p>
|
||||||
|
) : (
|
||||||
|
inventoryData?.lowStockItems.map((item) => (
|
||||||
|
<div key={item.id} className="p-3 border rounded-lg flex items-center justify-between hover:bg-muted/50">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm">{item.designation}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.reference} • {item.category}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-bold text-destructive">{item.stock} unités</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Min: {item.stockMin}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
356
src/components/suppliers/SupplierDetail.tsx
Normal file
356
src/components/suppliers/SupplierDetail.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
Package,
|
||||||
|
Edit,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface Supplier {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
contact: string | null
|
||||||
|
email: string | null
|
||||||
|
telephone: string | null
|
||||||
|
adresse: string | null
|
||||||
|
ville: string | null
|
||||||
|
codePostal: string | null
|
||||||
|
notes: string | null
|
||||||
|
actif: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
designation: string
|
||||||
|
categorie: string
|
||||||
|
stock: number
|
||||||
|
prixVenteTTC: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string
|
||||||
|
numero: string
|
||||||
|
date: string
|
||||||
|
montantHT: number
|
||||||
|
montantTVA: number
|
||||||
|
montantTTC: number
|
||||||
|
statut: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupplierDetailProps {
|
||||||
|
supplier: Supplier
|
||||||
|
onClose: () => void
|
||||||
|
onUpdate: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierDetail({ supplier, onClose, onUpdate, onEdit }: SupplierDetailProps) {
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSupplierData()
|
||||||
|
}, [supplier.id])
|
||||||
|
|
||||||
|
const fetchSupplierData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// Fetch products from this supplier
|
||||||
|
const productsResponse = await fetch(`/api/produits?fournisseurId=${supplier.id}`)
|
||||||
|
if (productsResponse.ok) {
|
||||||
|
const productsData = await productsResponse.json()
|
||||||
|
setProducts(Array.isArray(productsData) ? productsData : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoices from this supplier
|
||||||
|
const invoicesResponse = await fetch(`/api/fournisseurs/${supplier.id}/factures`)
|
||||||
|
if (invoicesResponse.ok) {
|
||||||
|
const invoicesData = await invoicesResponse.json()
|
||||||
|
setInvoices(Array.isArray(invoicesData) ? invoicesData : [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching supplier data:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fournisseurs/${supplier.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...supplier,
|
||||||
|
actif: !supplier.actif,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onUpdate()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling supplier status:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('fr-FR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPurchaseAmount = invoices.reduce((sum, inv) => sum + inv.montantTTC, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={onEdit} variant="outline" className="gap-2">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Modifier
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleToggleStatus}
|
||||||
|
variant={supplier.actif ? "destructive" : "default"}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{supplier.actif ? (
|
||||||
|
<>
|
||||||
|
<PowerOff className="h-4 w-4" />
|
||||||
|
Désactiver
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
Activer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier Information */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
Informations du fournisseur
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold">{supplier.nom}</h3>
|
||||||
|
{supplier.contact && (
|
||||||
|
<p className="text-sm text-gray-500">Contact: {supplier.contact}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={supplier.actif ? 'default' : 'secondary'}
|
||||||
|
className={
|
||||||
|
supplier.actif
|
||||||
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{supplier.actif ? 'Actif' : 'Inactif'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{supplier.telephone && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>{supplier.telephone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{supplier.email && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>{supplier.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(supplier.adresse || supplier.ville || supplier.codePostal) && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MapPin className="h-4 w-4 text-gray-500 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{supplier.adresse && <div>{supplier.adresse}</div>}
|
||||||
|
<div>
|
||||||
|
{supplier.codePostal && `${supplier.codePostal} `}
|
||||||
|
{supplier.ville}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-500" />
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Créé le {formatDate(supplier.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supplier.notes && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Notes</h4>
|
||||||
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{supplier.notes}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Produits</p>
|
||||||
|
<p className="text-2xl font-bold">{products.length}</p>
|
||||||
|
</div>
|
||||||
|
<Package className="h-8 w-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Factures</p>
|
||||||
|
<p className="text-2xl font-bold">{invoices.length}</p>
|
||||||
|
</div>
|
||||||
|
<FileText className="h-8 w-8 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Achats</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{formatCurrency(totalPurchaseAmount)}</p>
|
||||||
|
</div>
|
||||||
|
<Building2 className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5" />
|
||||||
|
Produits fournis ({products.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Chargement...</div>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
Aucun produit associé à ce fournisseur
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<div className="divide-y">
|
||||||
|
{products.map((product) => (
|
||||||
|
<div key={product.id} className="p-4 hover:bg-gray-50 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{product.designation}</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{product.reference} • {product.categorie}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-medium">{formatCurrency(product.prixVenteTTC)}</div>
|
||||||
|
<div className="text-sm text-gray-500">Stock: {product.stock}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Purchase History */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Historique des achats ({invoices.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">Chargement...</div>
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
Aucune facture d'achat associée à ce fournisseur
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
<div className="divide-y">
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<div key={invoice.id} className="p-4 hover:bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="font-medium">{invoice.numero}</div>
|
||||||
|
<Badge
|
||||||
|
variant={invoice.statut === 'VALIDE' ? 'default' : 'secondary'}
|
||||||
|
className={
|
||||||
|
invoice.statut === 'VALIDE'
|
||||||
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
|
: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{invoice.statut === 'VALIDE' ? 'Validé' : 'Brouillon'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="text-gray-500">{formatDate(invoice.date)}</div>
|
||||||
|
<div className="font-medium">{formatCurrency(invoice.montantTTC)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
298
src/components/suppliers/SupplierForm.tsx
Normal file
298
src/components/suppliers/SupplierForm.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const supplierSchema = z.object({
|
||||||
|
nom: z.string().min(1, 'Le nom est requis'),
|
||||||
|
contact: z.string().optional(),
|
||||||
|
email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
telephone: z.string().optional(),
|
||||||
|
adresse: z.string().optional(),
|
||||||
|
ville: z.string().optional(),
|
||||||
|
codePostal: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
actif: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
type SupplierFormValues = z.infer<typeof supplierSchema>
|
||||||
|
|
||||||
|
interface Supplier {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
contact: string | null
|
||||||
|
email: string | null
|
||||||
|
telephone: string | null
|
||||||
|
adresse: string | null
|
||||||
|
ville: string | null
|
||||||
|
codePostal: string | null
|
||||||
|
notes: string | null
|
||||||
|
actif: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupplierFormProps {
|
||||||
|
supplier?: Supplier | null
|
||||||
|
onSave: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierForm({ supplier, onSave, onCancel }: SupplierFormProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<SupplierFormValues>({
|
||||||
|
resolver: zodResolver(supplierSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nom: supplier?.nom || '',
|
||||||
|
contact: supplier?.contact || '',
|
||||||
|
email: supplier?.email || '',
|
||||||
|
telephone: supplier?.telephone || '',
|
||||||
|
adresse: supplier?.adresse || '',
|
||||||
|
ville: supplier?.ville || '',
|
||||||
|
codePostal: supplier?.codePostal || '',
|
||||||
|
notes: supplier?.notes || '',
|
||||||
|
actif: supplier?.actif ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = async (data: SupplierFormValues) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const url = supplier ? `/api/fournisseurs/${supplier.id}` : '/api/fournisseurs'
|
||||||
|
const method = supplier ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nom: data.nom,
|
||||||
|
contact: data.contact || null,
|
||||||
|
email: data.email || null,
|
||||||
|
telephone: data.telephone || null,
|
||||||
|
adresse: data.adresse || null,
|
||||||
|
ville: data.ville || null,
|
||||||
|
codePostal: data.codePostal || null,
|
||||||
|
notes: data.notes || null,
|
||||||
|
actif: data.actif,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onSave()
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
console.error('Error saving supplier:', error)
|
||||||
|
alert('Erreur lors de la sauvegarde du fournisseur: ' + (error.error || 'Erreur inconnue'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving supplier:', error)
|
||||||
|
alert('Erreur lors de la sauvegarde du fournisseur')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Informations générales</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nom"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nom de l'entreprise *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Optique Vision SAS" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="contact"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contact principal</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="M. Jean Dupont" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Coordonnées</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="telephone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Téléphone</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="0123456789" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" placeholder="contact@fournisseur.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Adresse</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="adresse"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Adresse</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="123 Zone Industrielle" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codePostal"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Code Postal</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="75001" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ville"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ville</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Paris" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Informations complémentaires</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="actif"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">Statut actif</FormLabel>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Un fournisseur inactif ne peut plus être sélectionné pour les achats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Notes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Conditions de paiement, délais de livraison, remarques..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{supplier ? 'Mettre à jour' : 'Créer le fournisseur'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
479
src/components/suppliers/SupplierList.tsx
Normal file
479
src/components/suppliers/SupplierList.tsx
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Building2,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { SupplierForm } from './SupplierForm'
|
||||||
|
import { SupplierDetail } from './SupplierDetail'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface Supplier {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
contact: string | null
|
||||||
|
email: string | null
|
||||||
|
telephone: string | null
|
||||||
|
adresse: string | null
|
||||||
|
ville: string | null
|
||||||
|
codePostal: string | null
|
||||||
|
notes: string | null
|
||||||
|
actif: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
_count?: {
|
||||||
|
produits: number
|
||||||
|
facturesAchat: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierList() {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||||
|
const [filteredSuppliers, setFilteredSuppliers] = useState<Supplier[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null)
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null)
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
|
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSuppliers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterSuppliers()
|
||||||
|
}, [searchTerm, statusFilter, suppliers])
|
||||||
|
|
||||||
|
const filterSuppliers = () => {
|
||||||
|
let filtered = [...suppliers]
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
filtered = filtered.filter(supplier =>
|
||||||
|
supplier.nom.toLowerCase().includes(term) ||
|
||||||
|
(supplier.contact && supplier.contact.toLowerCase().includes(term)) ||
|
||||||
|
(supplier.email && supplier.email.toLowerCase().includes(term)) ||
|
||||||
|
(supplier.telephone && supplier.telephone.includes(term)) ||
|
||||||
|
(supplier.ville && supplier.ville.toLowerCase().includes(term)) ||
|
||||||
|
(supplier.codePostal && supplier.codePostal.includes(term))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (statusFilter === 'active') {
|
||||||
|
filtered = filtered.filter(s => s.actif)
|
||||||
|
} else if (statusFilter === 'inactive') {
|
||||||
|
filtered = filtered.filter(s => !s.actif)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredSuppliers(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/fournisseurs')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSuppliers(data)
|
||||||
|
setFilteredSuppliers(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching suppliers:', error)
|
||||||
|
toast.error('Erreur lors du chargement des fournisseurs')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSupplierSaved = async () => {
|
||||||
|
setIsAddDialogOpen(false)
|
||||||
|
setEditingSupplier(null)
|
||||||
|
await fetchSuppliers()
|
||||||
|
toast.success(editingSupplier ? 'Fournisseur mis à jour' : 'Fournisseur créé')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewSupplier = (supplier: Supplier) => {
|
||||||
|
setSelectedSupplier(supplier)
|
||||||
|
setIsDetailDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSupplier = (supplier: Supplier) => {
|
||||||
|
setEditingSupplier(supplier)
|
||||||
|
setIsAddDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleStatus = async (supplier: Supplier) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fournisseurs/${supplier.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...supplier,
|
||||||
|
actif: !supplier.actif,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchSuppliers()
|
||||||
|
toast.success(supplier.actif ? 'Fournisseur désactivé' : 'Fournisseur activé')
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
toast.error(error.error || 'Erreur lors de la mise à jour')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling supplier status:', error)
|
||||||
|
toast.error('Erreur lors de la mise à jour du statut')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSupplier = async (supplier: Supplier) => {
|
||||||
|
if (!confirm(`Êtes-vous sûr de vouloir supprimer le fournisseur "${supplier.nom}" ?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/fournisseurs/${supplier.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchSuppliers()
|
||||||
|
toast.success('Fournisseur supprimé')
|
||||||
|
} else {
|
||||||
|
const error = await response.json()
|
||||||
|
toast.error(error.error || 'Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting supplier:', error)
|
||||||
|
toast.error('Erreur lors de la suppression')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = suppliers.filter(s => s.actif).length
|
||||||
|
const inactiveCount = suppliers.length - activeCount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Gestion des Fournisseurs</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{suppliers.length} fournisseur{suppliers.length !== 1 ? 's' : ''} enregistré{suppliers.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Nouveau Fournisseur
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingSupplier ? 'Modifier le Fournisseur' : 'Nouveau Fournisseur'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingSupplier
|
||||||
|
? 'Modifiez les informations du fournisseur'
|
||||||
|
: 'Remplissez les informations pour créer un nouveau fournisseur'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<SupplierForm
|
||||||
|
supplier={editingSupplier}
|
||||||
|
onSave={handleSupplierSaved}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsAddDialogOpen(false)
|
||||||
|
setEditingSupplier(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total</p>
|
||||||
|
<p className="text-2xl font-bold">{suppliers.length}</p>
|
||||||
|
</div>
|
||||||
|
<Building2 className="h-8 w-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Actifs</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{activeCount}</p>
|
||||||
|
</div>
|
||||||
|
<Power className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Inactifs</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-400">{inactiveCount}</p>
|
||||||
|
</div>
|
||||||
|
<PowerOff className="h-8 w-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par nom, contact, email, téléphone, ville..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
>
|
||||||
|
Tous ({suppliers.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'active' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
>
|
||||||
|
Actifs ({activeCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'inactive' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
>
|
||||||
|
Inactifs ({inactiveCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Suppliers Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<ScrollArea className="h-[600px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Fournisseur</TableHead>
|
||||||
|
<TableHead>Contact</TableHead>
|
||||||
|
<TableHead>Adresse</TableHead>
|
||||||
|
<TableHead>Produits</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8">
|
||||||
|
Chargement...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredSuppliers.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Building2 className="h-12 w-12 text-gray-400" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{searchTerm || statusFilter !== 'all'
|
||||||
|
? 'Aucun fournisseur trouvé'
|
||||||
|
: 'Aucun fournisseur enregistré'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredSuppliers.map((supplier) => (
|
||||||
|
<TableRow key={supplier.id} className="hover:bg-gray-50">
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{supplier.nom}</div>
|
||||||
|
{supplier.contact && (
|
||||||
|
<div className="text-sm text-gray-500">{supplier.contact}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{supplier.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="h-3 w-3 text-gray-400" />
|
||||||
|
<span className="truncate max-w-[150px]">{supplier.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{supplier.telephone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Phone className="h-3 w-3 text-gray-400" />
|
||||||
|
<span>{supplier.telephone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{supplier.adresse || supplier.ville || supplier.codePostal ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{supplier.adresse && (
|
||||||
|
<div className="text-sm text-gray-600 truncate max-w-[150px]">
|
||||||
|
{supplier.adresse}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{supplier.codePostal && `${supplier.codePostal} `}
|
||||||
|
{supplier.ville}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">Non renseignée</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{supplier._count?.produits || 0}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={supplier.actif ? 'default' : 'secondary'}
|
||||||
|
className={
|
||||||
|
supplier.actif
|
||||||
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{supplier.actif ? 'Actif' : 'Inactif'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewSupplier(supplier)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditSupplier(supplier)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggleStatus(supplier)}
|
||||||
|
title={supplier.actif ? 'Désactiver' : 'Activer'}
|
||||||
|
>
|
||||||
|
{supplier.actif ? (
|
||||||
|
<PowerOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteSupplier(supplier)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Supplier Detail Dialog */}
|
||||||
|
<Dialog open={isDetailDialogOpen} onOpenChange={setIsDetailDialogOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Détail du Fournisseur</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Informations complètes et historique des achats
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedSupplier && (
|
||||||
|
<SupplierDetail
|
||||||
|
supplier={selectedSupplier}
|
||||||
|
onClose={() => setIsDetailDialogOpen(false)}
|
||||||
|
onUpdate={fetchSuppliers}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingSupplier(selectedSupplier)
|
||||||
|
setIsDetailDialogOpen(false)
|
||||||
|
setIsAddDialogOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/components/ui/accordion.tsx
Normal file
66
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
11
src/components/ui/aspect-ratio.tsx
Normal file
11
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
241
src/components/ui/carousel.tsx
Normal file
241
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react"
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user