Initial commit: SecourIT Gestion

This commit is contained in:
2026-08-23 09:06:18 +00:00
commit cab93c7f7c
85 changed files with 15801 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.next/
.env
*.log
.DS_Store
+112
View File
@@ -0,0 +1,112 @@
# SecourIT - Gestion
Application de gestion commerciale : clients (prospects/clients), devis avec calcul de marge et frais de déplacement, contrats, factures, ventes, coûts de produits, to-do list. Interface en français, PWA.
## Stack technique
| Composant | Technologie |
|-----------|------------|
| Framework | Next.js 14 (App Router) |
| Langage | TypeScript |
| Base de données | PostgreSQL 17 (LXC distante) |
| ORM | Prisma 5 |
| Authentification | JWT (jose) + bcryptjs |
| UI | Tailwind CSS + Lucide React |
| Formulaires | react-hook-form + zod |
| Graphes | Recharts |
| Emails | Nodemailer (SMTP Infomaniak) |
## Déploiement
```bash
cd /opt/gestion
# Installer les dépendances
npm install
# Générer le client Prisma
npx prisma generate
# Pousser le schema en BDD
npx prisma db push
# Lancer en production
NODE_ENV=production npx next start -p 3005
```
> **Note** : Pas de process manager en production. Le process est lancé via `nohup`. Un redémarrage automatique n'est pas garanti.
## Structure
```
/opt/gestion/
├── src/
│ ├── app/
│ │ ├── api/ # Routes API (App Router)
│ │ │ ├── auth/ # Login, logout, register, me, change-password
│ │ │ ├── clients/ # CRUD clients
│ │ │ ├── quotes/ # Devis
│ │ │ ├── contracts/ # Contrats
│ │ │ ├── invoices/ # Factures
│ │ │ ├── sales/ # Ventes
│ │ │ ├── servers/ # Gestion serveurs
│ │ │ ├── tickets/ # Support/tickets
│ │ │ ├── todos/ # To-do list
│ │ │ └── dashboard/ # Tableau de bord
│ │ ├── login/ # Page de connexion
│ │ └── layout.tsx # Layout principal
│ └── lib/ # Utilitaires (auth, BDD)
├── prisma/
│ ├── schema.prisma # Modèles de données
│ ├── seed.js # Données initiales
│ └── seed-demo.js # Données de démonstration
├── public/ # Assets statiques
├── .env # Variables d'environnement
├── next.config.js # Config Next.js
├── tailwind.config.js # Config Tailwind
└── package.json
```
## Modèles de données (Prisma)
- **User** - Utilisateurs avec sessions
- **Client** - Prospects et clients (type enum PROSPECT/CLIENT)
- **Quote** / **QuoteItem** - Devis avec lignes, marge, frais déplacement
- **Contract** - Contrats liés aux devis acceptés
- **Invoice** - Factures
- **Sale** / **SaleItem** - Ventes avec lignes
- **ProductCost** - Coûts des produits
- **TodoItem** - Liste de tâches
## Variables d'environnement
| Variable | Description | Valeur |
|----------|-------------|--------|
| `DATABASE_URL` | Connexion PostgreSQL | `postgresql://gestion_user:L0relei69@10.10.10.10:5432/gestion_secourit` |
| `JWT_SECRET` | Secret JWT | configuré dans .env |
| `SERVER_MONITOR_SECRET` | Secret monitoring | configuré dans .env |
| `SMTP_HOST` | Serveur SMTP | `smtp.infomaniak.ch` |
| `SMTP_PORT` | Port SMTP | `465` |
| `SMTP_USER` | Email SMTP | `support@secourit.fr` |
| `SMTP_PASS` | Mot de passe SMTP | configuré dans .env |
| `NODE_ENV` | Environnement | `production` |
## Commandes utiles
```bash
npx prisma db push # Pousser le schema en BDD
npx prisma generate # Générer le client Prisma
npx prisma studio # Interface web pour la BDD
npx prisma migrate reset # Reset complet de la BDD
node prisma/seed.js # Insérer les données initiales
node prisma/seed-demo.js # Insérer des données de test
```
## Domaine de production
- **URL** : https://gestion.secourit.fr
- **LXC** : 104 (IP 10.10.10.21)
- **Port** : 3005
- **SSL** : Let's Encrypt via Nginx Proxy Manager
- **BDD** : PostgreSQL sur LXC 101 (10.10.10.10)
- **Preprod** : https://gestion-preprod.secourit.fr (LXC 110, port 3105)
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
serverActions: { bodySizeLimit: '2mb' },
},
}
module.exports = nextConfig
+6731
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "gestion-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3005",
"build": "next build",
"start": "next start -p 3005",
"lint": "next lint",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"db:seed": "node prisma/seed.js",
"db:seed-demo": "node prisma/seed-demo.js",
"db:reset": "prisma migrate reset --force"
},
"dependencies": {
"@hookform/resolvers": "^3.3.0",
"@prisma/client": "^5.10.0",
"bcryptjs": "^2.4.3",
"clsx": "^2.1.0",
"date-fns": "^3.3.0",
"jose": "^5.2.0",
"lucide-react": "^0.344.0",
"next": "14.2.0",
"nodemailer": "^9.0.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.51.0",
"recharts": "^2.12.0",
"tailwind-merge": "^2.2.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.11.0",
"@types/nodemailer": "^8.0.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.56.0",
"eslint-config-next": "14.2.0",
"postcss": "^8.4.0",
"prisma": "^5.10.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+253
View File
@@ -0,0 +1,253 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String?
role String @default("admin")
infomaniakId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
quotes Quote[]
contracts Contract[]
clients Client[]
sales Sale[]
todos TodoItem[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
}
model Client {
id String @id @default(cuid())
name String
email String?
phone String?
address String?
siret String?
type ClientType @default(PROSPECT)
isProfessional Boolean? @default(true)
notes String?
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
quotes Quote[]
contracts Contract[]
sales Sale[]
@@index([userId])
@@index([type])
}
enum ClientType {
PROSPECT
CLIENT
}
model Quote {
id String @id @default(cuid())
clientId String
userId String
name String
quoteNumber String?
status QuoteStatus @default(DRAFT)
setupTotal Float @default(0)
monthlyTotal Float @default(0)
costTotal Float @default(0)
marginTotal Float @default(0)
discount Float @default(0)
travelKm Int @default(0)
travelCost Float @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items QuoteItem[]
contract Contract?
@@index([clientId])
@@index([userId])
@@index([status])
}
enum QuoteStatus {
DRAFT
PROPOSAL
SENT
ACCEPTED
REJECTED
EXPIRED
}
model QuoteItem {
id String @id @default(cuid())
quoteId String
type QuoteItemType
label String
description String?
unitPrice Float
costPrice Float
quantity Int @default(1)
isRecurring Boolean @default(false)
setupPrice Float @default(0)
setupCost Float @default(0)
monthlyPrice Float @default(0)
monthlyCost Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
@@index([quoteId])
}
enum QuoteItemType {
MAINTENANCE_POSTE
MAINTENANCE_SERVER
KS_SUITE_STANDARD
KS_SUITE_BUSINESS
KS_SUITE_ENTREPRISE
HOURLY_INTERVENTION
TRAVEL
CUSTOM
}
model Contract {
id String @id @default(cuid())
clientId String
userId String
quoteId String? @unique
name String
startDate DateTime
endDate DateTime?
setupPrice Float @default(0)
setupCost Float @default(0)
monthlyPrice Float @default(0)
monthlyCost Float @default(0)
status ContractStatus @default(ACTIVE)
billingDay Int @default(1)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
quote Quote? @relation(fields: [quoteId], references: [id], onDelete: SetNull)
invoices Invoice[]
@@index([clientId])
@@index([userId])
@@index([status])
}
enum ContractStatus {
PROPOSAL
ACTIVE
PAUSED
CANCELLED
EXPIRED
}
model Invoice {
id String @id @default(cuid())
contractId String
month DateTime
amount Float
invoiceNumber String?
emitted Boolean @default(false)
emittedAt DateTime?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
@@index([contractId])
@@unique([contractId, month])
}
model TodoItem {
id String @id @default(cuid())
userId String
text String
position Int
done Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model Sale {
id String @id @default(cuid())
clientId String
userId String
date DateTime @default(now())
totalAmount Float @default(0)
totalCost Float @default(0)
paid Boolean @default(false)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items SaleItem[]
@@index([clientId])
@@index([userId])
}
model SaleItem {
id String @id @default(cuid())
saleId String
label String
description String?
quantity Int @default(1)
unitPrice Float @default(0)
costPrice Float @default(0)
createdAt DateTime @default(now())
sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade)
@@index([saleId])
}
model ProductCost {
id String @id @default(cuid())
key String @unique
label String
costPrice Float
sellPrice Float
unit String
category String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+93
View File
@@ -0,0 +1,93 @@
const { PrismaClient } = require('@prisma/client')
const bcrypt = require('bcryptjs')
const prisma = new PrismaClient()
async function main() {
const passwordHash = await bcrypt.hash('demo123', 12)
const user = await prisma.user.upsert({
where: { email: 'demo@secourit.fr' },
update: {},
create: {
email: 'demo@secourit.fr',
name: 'Démo',
passwordHash,
role: 'demo',
},
})
console.log('Demo user created:', user.email)
// Nettoyer les anciennes données démo
await prisma.contract.deleteMany({ where: { userId: user.id } })
await prisma.quote.deleteMany({ where: { userId: user.id } })
await prisma.client.deleteMany({ where: { userId: user.id } })
// Créer des clients
const clients = await Promise.all([
prisma.client.create({
data: { name: 'SARL Dupont Informatique', email: 'contact@dupont-info.fr', phone: '01 23 45 67 89', address: '12 Rue de Paris, 75001 Paris', siret: '12345678900012', type: 'CLIENT', notes: 'Client principal', userId: user.id },
}),
prisma.client.create({
data: { name: 'Cabinet Médical Martin', email: 'secretariat@cabinet-martin.fr', phone: '01 98 76 54 32', address: '5 Avenue des Lilas, 69001 Lyon', siret: '98765432100045', type: 'CLIENT', notes: '3 postes + 1 serveur', userId: user.id },
}),
prisma.client.create({
data: { name: 'Boulangerie Le Pain Doré', email: 'contact@lepaindore.fr', phone: '03 20 15 30 45', address: '8 Rue du Four, 59000 Lille', siret: '45678912300078', type: 'CLIENT', notes: '1 poste, maintenance simple', userId: user.id },
}),
prisma.client.create({
data: { name: 'SCI Bellevue', email: 'gestion@scibellevue.com', phone: '04 91 23 45 67', address: '15 Boulevard de la Mer, 13008 Marseille', siret: '32165498700023', type: 'PROSPECT', notes: 'Projet 5 postes + KSuite', userId: user.id },
}),
prisma.client.create({
data: { name: 'Restaurant Le Gourmet', email: 'contact@legourmet.fr', phone: '02 40 12 34 56', address: '3 Place du Marché, 44000 Nantes', siret: '65412378900056', type: 'PROSPECT', notes: 'A besoin de 2 caisses', userId: user.id },
}),
prisma.client.create({
data: { name: 'École Privée Saint-Joseph', email: 'direction@saint-joseph.fr', phone: '05 56 78 90 12', address: '20 Rue de lÉducation, 33000 Bordeaux', siret: '14725836900089', type: 'CLIENT', notes: '10 postes, 2 serveurs, KSuite Business 15 utilisateurs', userId: user.id },
}),
])
console.log('Clients created:', clients.length)
// Créer des contrats
const contracts = [
{ clientId: clients[0].id, name: 'Contrat maintenance SARL Dupont', startDate: new Date('2025-01-01'), monthlyPrice: 76.80, monthlyCost: 11.20, setupPrice: 500, setupCost: 150, status: 'ACTIVE' },
{ clientId: clients[0].id, name: 'KSuite Business - SARL Dupont (3 users)', startDate: new Date('2025-01-01'), monthlyPrice: 47.40, monthlyCost: 19.74, setupPrice: 0, setupCost: 0, status: 'ACTIVE' },
{ clientId: clients[1].id, name: 'Contrat maintenance Cabinet Médical', startDate: new Date('2025-03-15'), monthlyPrice: 54.00, monthlyCost: 8.60, setupPrice: 800, setupCost: 200, status: 'ACTIVE' },
{ clientId: clients[2].id, name: 'Maintenance Boulangerie', startDate: new Date('2025-06-01'), monthlyPrice: 9.60, monthlyCost: 1.40, setupPrice: 150, setupCost: 50, status: 'ACTIVE' },
{ clientId: clients[5].id, name: 'Contrat global École St-Joseph', startDate: new Date('2025-09-01'), monthlyPrice: 333.00, monthlyCost: 115.70, setupPrice: 2500, setupCost: 800, status: 'ACTIVE' },
{ clientId: clients[1].id, name: 'KSuite Business - Cabinet Médical (2 users)', startDate: new Date('2025-04-01'), monthlyPrice: 31.60, monthlyCost: 13.16, setupPrice: 0, setupCost: 0, status: 'PAUSED' },
]
for (const contract of contracts) {
await prisma.contract.create({
data: { ...contract, userId: user.id, billingDay: 1, notes: null },
})
}
console.log('Contracts created:', contracts.length)
// Créer des devis
const quotes = [
{ clientId: clients[3].id, name: 'Devis SCI Bellevue - 5 postes', status: 'DRAFT', setupTotal: 250, monthlyTotal: 48, costTotal: 7, marginTotal: 41, discount: 0, travelKm: 30, travelCost: 15 },
{ clientId: clients[4].id, name: 'Devis Restaurant Le Gourmet', status: 'SENT', setupTotal: 300, monthlyTotal: 19.20, costTotal: 2.80, marginTotal: 16.40, discount: 10, travelKm: 15, travelCost: 7.50 },
]
for (const quote of quotes) {
await prisma.quote.create({
data: { ...quote, userId: user.id, notes: null },
})
}
console.log('Quotes created:', quotes.length)
console.log('')
console.log('Demo account: demo@secourit.fr / demo123')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+50
View File
@@ -0,0 +1,50 @@
const { PrismaClient } = require('@prisma/client')
const bcrypt = require('bcryptjs')
const prisma = new PrismaClient()
async function main() {
const passwordHash = await bcrypt.hash('admin123', 12)
const user = await prisma.user.upsert({
where: { email: 'pierre@secourit.fr' },
update: {},
create: {
email: 'pierre@secourit.fr',
name: 'Pierre',
passwordHash,
role: 'admin',
},
})
console.log('User created:', user.email)
const defaultProducts = [
{ key: 'maintenance-poste', label: 'Maintenance Poste (Windows/Mac)', costPrice: 1.40, sellPrice: 9.60, unit: 'mois', category: 'maintenance' },
{ key: 'maintenance-serveur', label: 'Maintenance Serveur', costPrice: 4.20, sellPrice: 25.20, unit: 'mois', category: 'maintenance' },
{ key: 'ksuite-business', label: 'KSuite Business / utilisateur', costPrice: 6.58, sellPrice: 15.80, unit: 'mois', category: 'ksuite' },
{ key: 'ksuite-standard', label: 'KSuite Standard / utilisateur', costPrice: 1.58, sellPrice: 0, unit: 'mois', category: 'ksuite' },
{ key: 'ksuite-entreprise', label: 'KSuite Entreprise / utilisateur', costPrice: 12.41, sellPrice: 0, unit: 'mois', category: 'ksuite' },
{ key: 'intervention-horaire', label: 'Intervention (Taux horaire)', costPrice: 0, sellPrice: 50, unit: 'heure', category: 'hourly' },
{ key: 'deplacement-km', label: 'Déplacement (par km)', costPrice: 0.30, sellPrice: 0.50, unit: 'km', category: 'travel' },
]
for (const product of defaultProducts) {
await prisma.productCost.upsert({
where: { key: product.key },
update: product,
create: product,
})
}
console.log('Default products created')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#2563eb"/>
<polyline points="15,75 35,45 50,55 70,25" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="65,25 70,25 70,35" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="70" cy="25" r="3" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "SecourIT - Gestion",
"short_name": "SecourIT",
"description": "Application de gestion commerciale SecourIT",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#2563eb",
"icons": [
{
"src": "/logo-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
self.addEventListener('install', (e) => {
self.skipWaiting()
})
self.addEventListener('activate', (e) => {
e.waitUntil(clients.claim())
})
self.addEventListener('fetch', (e) => {
e.respondWith(fetch(e.request))
})
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
SERVER_ID="$1"
API_URL="$2"
TOKEN="$3"
if [ -z "$SERVER_ID" ] || [ -z "$API_URL" ] || [ -z "$TOKEN" ]; then
echo "Usage: $0 SERVER_ID API_URL SECRET_TOKEN"
exit 1
fi
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4}' | head -1)
TOTAL_RAM=$(free -m | awk '/Mem:/ {print $2}')
USED_RAM=$(free -m | awk '/Mem:/ {print $3}')
RAM_USAGE=$(awk "BEGIN {printf \"%.2f\", ($USED_RAM / $TOTAL_RAM) * 100}")
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | sed 's/%//')
UPTIME=$(awk '{print $1}' /proc/uptime | cut -d. -f1)
curl -s -X POST "$API_URL?token=$TOKEN" \
-H "Content-Type: application/json" \
--data-raw "{\"cpuUsage\": $CPU_USAGE, \"ramUsage\": $RAM_USAGE, \"diskUsage\": $DISK_USAGE, \"uptime\": $UPTIME}"
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, hashPassword, verifyPassword } from '@/lib/auth'
export async function PUT(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const { currentPassword, newPassword } = await request.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Mot de passe actuel et nouveau mot de passe requis' }, { status: 400 })
}
if (newPassword.length < 6) {
return NextResponse.json({ error: 'Le nouveau mot de passe doit faire au moins 6 caractères' }, { status: 400 })
}
const dbUser = await prisma.user.findUnique({ where: { id: user.id } })
if (!dbUser || !dbUser.passwordHash) {
return NextResponse.json({ error: 'Utilisateur non trouvé' }, { status: 404 })
}
const valid = await verifyPassword(currentPassword, dbUser.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Mot de passe actuel incorrect' }, { status: 400 })
}
const passwordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: user.id },
data: { passwordHash },
})
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyPassword, createToken, setAuthCookie } from '@/lib/auth'
export async function POST(request: Request) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json({ error: 'Email et mot de passe requis' }, { status: 400 })
}
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.passwordHash) {
return NextResponse.json({ error: 'Email ou mot de passe incorrect' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Email ou mot de passe incorrect' }, { status: 401 })
}
const token = await createToken(user.id, user.email)
await prisma.session.create({
data: { userId: user.id, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
})
const response = NextResponse.json({ success: true })
await setAuthCookie(token)
return response
} catch (error) {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { clearAuthCookie, getSession } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function POST() {
try {
const session = await getSession()
if (session) {
await prisma.session.deleteMany({ where: { userId: session.userId } })
}
const response = NextResponse.json({ success: true })
await clearAuthCookie()
return response
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
}
return NextResponse.json({ user })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { hashPassword, createToken, setAuthCookie } from '@/lib/auth'
export async function POST(request: Request) {
try {
const { email, password, name } = await request.json()
if (!email || !password) {
return NextResponse.json({ error: 'Email et mot de passe requis' }, { status: 400 })
}
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
return NextResponse.json({ error: 'Cet email est déjà utilisé' }, { status: 400 })
}
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: { email, name: name || email.split('@')[0], passwordHash },
})
const token = await createToken(user.id, user.email)
await prisma.session.create({
data: { userId: user.id, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
})
const response = NextResponse.json({ success: true, user: { id: user.id, email: user.email, name: user.name } })
await setAuthCookie(token)
return response
} catch (error) {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+91
View File
@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const client = await prisma.client.findFirst({
where: { id: params.id, userId: user.id },
include: {
quotes: { orderBy: { createdAt: 'desc' } },
contracts: { orderBy: { createdAt: 'desc' } },
},
})
if (!client) return NextResponse.json({ error: 'Client non trouvé' }, { status: 404 })
const servers: any[] = await prisma.$queryRawUnsafe(`
SELECT s.id, s.name, s.ip, s."monthlyCost"
FROM "Server" s
JOIN "ClientServer" cs ON cs."serverId" = s.id
WHERE cs."clientId" = $1
`, params.id)
return NextResponse.json({ client, servers })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PUT(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const client = await prisma.client.updateMany({
where: { id: params.id, userId: user.id },
data: {
name: data.name,
email: data.email,
phone: data.phone,
address: data.address,
siret: data.siret,
type: data.type,
notes: data.notes,
},
})
if (client.count === 0) return NextResponse.json({ error: 'Client non trouvé' }, { status: 404 })
if (data.serverIds !== undefined) {
await prisma.$executeRawUnsafe(`DELETE FROM "ClientServer" WHERE "clientId" = $1`, params.id)
for (const serverId of data.serverIds) {
await prisma.$executeRawUnsafe(`
INSERT INTO "ClientServer" ("clientId", "serverId") VALUES ($1, $2)
`, params.id, serverId)
}
}
const updated = await prisma.client.findUnique({ where: { id: params.id } })
const servers: any[] = await prisma.$queryRawUnsafe(`
SELECT s.id, s.name, s.ip, s."monthlyCost"
FROM "Server" s
JOIN "ClientServer" cs ON cs."serverId" = s.id
WHERE cs."clientId" = $1
`, params.id)
return NextResponse.json({ client: updated, servers })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
await prisma.$executeRawUnsafe(`DELETE FROM "ClientServer" WHERE "clientId" = $1`, params.id)
const client = await prisma.client.deleteMany({
where: { id: params.id, userId: user.id },
})
if (client.count === 0) return NextResponse.json({ error: 'Client non trouvé' }, { status: 404 })
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+126
View File
@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { differenceInMonths, startOfMonth } from 'date-fns'
export async function GET(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const where: any = { userId: user.id }
if (type === 'PROSPECT' || type === 'CLIENT') where.type = type
const clientsRaw = await prisma.client.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { quotes: true, contracts: true, sales: true } },
contracts: { select: { monthlyPrice: true, setupPrice: true, startDate: true, endDate: true, status: true } },
sales: { select: { totalAmount: true, paid: true } },
},
})
const serverCosts: any[] = await prisma.$queryRawUnsafe(`
SELECT cs."clientId", s."monthlyCost",
(SELECT COUNT(*) FROM "ClientServer" cs2 WHERE cs2."serverId" = s.id) as "clientCount"
FROM "ClientServer" cs
JOIN "Server" s ON s.id = cs."serverId"
`)
const costByClient: Record<string, number> = {}
for (const sc of serverCosts) {
const share = sc.clientCount > 0 ? Number(sc.monthlyCost) / Number(sc.clientCount) : 0
costByClient[sc.clientId] = (costByClient[sc.clientId] || 0) + share
}
const serverCounts: any[] = await prisma.$queryRawUnsafe(`
SELECT "clientId", COUNT(*) as count
FROM "ClientServer"
GROUP BY "clientId"
`)
const countByClient: Record<string, number> = {}
for (const sc of serverCounts) {
countByClient[sc.clientId] = Number(sc.count)
}
const now = new Date()
const clients = clientsRaw.map((c) => {
const activeContracts = c.contracts.filter(ct => ct.status === 'ACTIVE')
const contractSetup = activeContracts.reduce((s, ct) => s + ct.setupPrice, 0)
const contractTotalMonthly = activeContracts.reduce((s, ct) => {
const start = startOfMonth(ct.startDate)
const end = ct.endDate ? startOfMonth(ct.endDate) : startOfMonth(now)
const months = Math.max(0, differenceInMonths(end, start) + 1)
return s + ct.monthlyPrice * months
}, 0)
const salesTotal = c.sales.reduce((s, sale) => s + sale.totalAmount, 0)
const totalInvoiced = contractSetup + contractTotalMonthly + salesTotal
const totalPaid = c.sales.filter((s) => s.paid).reduce((s, sale) => s + sale.totalAmount, 0)
const totalUnpaid = totalInvoiced - totalPaid
return {
id: c.id,
name: c.name,
email: c.email,
phone: c.phone,
address: c.address,
siret: c.siret,
type: c.type,
isProfessional: c.isProfessional,
notes: c.notes,
userId: c.userId,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
_count: c._count,
contractSetup,
contractTotalMonthly,
contractMonthly: c.contracts.reduce((s, ct) => s + ct.monthlyPrice, 0),
salesTotal,
totalInvoiced,
totalPaid,
totalUnpaid,
serverCount: countByClient[c.id] || 0,
serverCostShare: costByClient[c.id] || 0,
}
})
return NextResponse.json({ clients })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const client = await prisma.client.create({
data: {
name: data.name,
email: data.email || null,
phone: data.phone || null,
address: data.address || null,
siret: data.siret || null,
type: data.type || 'PROSPECT',
notes: data.notes || null,
userId: user.id,
},
})
if (data.serverIds && data.serverIds.length > 0) {
for (const serverId of data.serverIds) {
await prisma.$executeRawUnsafe(`
INSERT INTO "ClientServer" ("clientId", "serverId") VALUES ($1, $2)
`, client.id, serverId)
}
}
return NextResponse.json({ client }, { status: 201 })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { startOfMonth, eachMonthOfInterval, isBefore, isAfter } from 'date-fns'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const contract = await prisma.contract.findFirst({
where: { id: params.id, userId: user.id },
include: { client: true, invoices: true },
})
if (!contract) return NextResponse.json({ error: 'Contrat non trouvé' }, { status: 404 })
return NextResponse.json({ contract })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PUT(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const oldContract: any[] = await prisma.$queryRawUnsafe(`
SELECT status, "startDate", "monthlyPrice" FROM "Contract" WHERE id = $1 AND "userId" = $2
`, params.id, user.id)
const contract = await prisma.contract.updateMany({
where: { id: params.id, userId: user.id },
data: {
clientId: data.clientId,
name: data.name,
startDate: data.startDate ? new Date(data.startDate) : undefined,
endDate: data.endDate ? new Date(data.endDate) : null,
setupPrice: data.setupPrice || 0,
setupCost: data.setupCost || 0,
monthlyPrice: data.monthlyPrice || 0,
monthlyCost: data.monthlyCost || 0,
status: data.status,
billingDay: data.billingDay,
notes: data.notes,
},
})
if (contract.count === 0) return NextResponse.json({ error: 'Contrat non trouvé' }, { status: 404 })
// Auto-generate invoices for ACTIVE contracts (on status change or startDate change)
if ((data.status === 'ACTIVE' || oldContract[0]?.status === 'ACTIVE') && (data.monthlyPrice || oldContract[0]?.monthlyPrice) > 0) {
const startDate = data.startDate || oldContract[0]?.startDate || new Date()
const startMonth = startOfMonth(new Date(startDate))
const today = startOfMonth(new Date())
const months = eachMonthOfInterval({ start: startMonth, end: today })
for (const month of months) {
if (isBefore(month, startMonth)) continue
if (isAfter(month, today)) continue
const existing: any[] = await prisma.$queryRawUnsafe(`
SELECT id FROM "Invoice" WHERE "contractId" = $1 AND month = $2
`, params.id, month)
if (existing.length > 0) continue
await prisma.$executeRawUnsafe(`
INSERT INTO "Invoice" ("contractId", month, amount)
VALUES ($1, $2, $3)
`, params.id, month, data.monthlyPrice || oldContract[0]?.monthlyPrice || 0)
}
}
const updated = await prisma.contract.findUnique({
where: { id: params.id },
include: { client: true },
})
return NextResponse.json({ contract: updated })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const contract = await prisma.contract.deleteMany({
where: { id: params.id, userId: user.id },
})
if (contract.count === 0) return NextResponse.json({ error: 'Contrat non trouvé' }, { status: 404 })
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const contracts = await prisma.contract.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
include: {
client: { select: { id: true, name: true } },
_count: { select: { invoices: true } },
},
})
return NextResponse.json({ contracts })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const contract = await prisma.contract.create({
data: {
clientId: data.clientId,
userId: user.id,
quoteId: data.quoteId || null,
name: data.name,
startDate: new Date(data.startDate),
endDate: data.endDate ? new Date(data.endDate) : null,
setupPrice: data.setupPrice || 0,
setupCost: data.setupCost || 0,
monthlyPrice: data.monthlyPrice || 0,
monthlyCost: data.monthlyCost || 0,
status: data.status || 'ACTIVE',
billingDay: data.billingDay || 1,
notes: data.notes || null,
},
include: { client: true },
})
return NextResponse.json({ contract }, { status: 201 })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+287
View File
@@ -0,0 +1,287 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { startOfMonth, endOfMonth, startOfYear, eachMonthOfInterval, format } from 'date-fns'
import { fr } from 'date-fns/locale'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const now = new Date()
const monthStart = startOfMonth(now)
const monthEnd = endOfMonth(now)
const yearStart = startOfYear(now)
const contracts = await prisma.contract.findMany({
where: { userId: user.id, status: 'ACTIVE' },
select: { monthlyPrice: true, monthlyCost: true, setupPrice: true, setupCost: true },
})
const serverCosts: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(SUM(s."monthlyCost"), 0) as total
FROM "Server" s
WHERE s."userId" = $1 AND s.status = 'ACTIVE'
`, user.id)
const serverMonthlyCost = Number(serverCosts[0]?.total || 0)
const clientServerCounts: any[] = await prisma.$queryRawUnsafe(`
SELECT cs."serverId", s."monthlyCost", COUNT(cs2."clientId") as client_count
FROM "ClientServer" cs
JOIN "Server" s ON s.id = cs."serverId"
JOIN "ClientServer" cs2 ON cs2."serverId" = s.id
GROUP BY cs."serverId", s."monthlyCost"
`)
let totalServerCostShared = 0
for (const row of clientServerCounts) {
if (row.client_count > 0) {
totalServerCostShared += Number(row.monthlyCost) / Number(row.client_count)
}
}
const monthlyRecurringRevenue = contracts.reduce((sum: number, c) => sum + c.monthlyPrice, 0)
const contractRecurringCost = contracts.reduce((sum: number, c) => sum + c.monthlyCost, 0)
const monthlyRecurringCost = contractRecurringCost + serverMonthlyCost
const monthlyMargin = monthlyRecurringRevenue - monthlyRecurringCost
const monthlyMarginPercent = monthlyRecurringRevenue > 0 ? (monthlyMargin / monthlyRecurringRevenue) * 100 : 0
const monthSales = await prisma.sale.findMany({
where: { userId: user.id, date: { gte: monthStart, lte: monthEnd }, paid: true },
select: { totalAmount: true, totalCost: true },
})
const salesThisMonth = monthSales.reduce((sum, s) => sum + s.totalAmount, 0)
const salesCostThisMonth = monthSales.reduce((sum, s) => sum + s.totalCost, 0)
const costThisMonth = contractRecurringCost + salesCostThisMonth
const monthInvoices: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(SUM(inv.amount), 0) as total
FROM "Invoice" inv
JOIN "Contract" c ON c.id = inv."contractId"
WHERE c."userId" = $1 AND inv.month >= $2 AND inv.month <= $3
`, user.id, monthStart, monthEnd)
const invoicedThisMonth = salesThisMonth + (Number(monthInvoices[0]?.total) || 0)
const activeClients = await prisma.client.count({
where: { userId: user.id, type: 'CLIENT' },
})
const prospectsCount = await prisma.client.count({
where: { userId: user.id, type: 'PROSPECT' },
})
const allClients = await prisma.client.findMany({
where: { userId: user.id, type: 'CLIENT' },
include: {
contracts: { where: { status: 'ACTIVE' }, select: { monthlyPrice: true } },
sales: { where: { paid: true }, select: { totalAmount: true } },
},
})
const invoiceByClient: any[] = await prisma.$queryRawUnsafe(`
SELECT c."clientId", COALESCE(SUM(inv.amount), 0) as total
FROM "Invoice" inv
JOIN "Contract" c ON c.id = inv."contractId"
WHERE c."userId" = $1
GROUP BY c."clientId"
`, user.id)
const invoiceByClientMap: Record<string, number> = {}
for (const row of invoiceByClient) {
invoiceByClientMap[row.clientId] = Number(row.total)
}
const topClients = allClients
.map((c) => {
const monthlyRecurring = c.contracts.reduce((sum, ct) => sum + ct.monthlyPrice, 0)
const oneTimeTotal = c.sales.reduce((sum, s) => sum + s.totalAmount, 0)
const invoiceTotal = invoiceByClientMap[c.id] || 0
return {
id: c.id,
name: c.name,
monthlyRecurring,
oneTimeTotal,
invoiceTotal,
totalRevenue: monthlyRecurring + oneTimeTotal + invoiceTotal,
contractCount: c.contracts.length,
}
})
.sort((a, b) => b.totalRevenue - a.totalRevenue)
.slice(0, 5)
const months = eachMonthOfInterval({ start: yearStart, end: now })
const allContractsForChart = await prisma.contract.findMany({
where: { userId: user.id },
select: { startDate: true, endDate: true, monthlyPrice: true, monthlyCost: true, setupPrice: true, setupCost: true, status: true },
})
const allUserContracts = await prisma.contract.findMany({
where: { userId: user.id },
select: { status: true, monthlyPrice: true },
})
const proposalContracts = allUserContracts.filter(c => (c.status as string) === 'PROPOSAL')
const pausedContracts = allUserContracts.filter(c => (c.status as string) === 'PAUSED')
const pendingProposalMonthly = proposalContracts.reduce((sum, c) => sum + c.monthlyPrice, 0)
const pendingProposalCount = proposalContracts.length
const pausedMonthly = pausedContracts.reduce((sum, c) => sum + c.monthlyPrice, 0)
const pausedCount = pausedContracts.length
const topQuotes = await prisma.quote.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 5,
include: {
client: { select: { name: true } },
},
})
const topQuotesSorted = topQuotes
.map((q) => ({
id: q.id,
name: q.name,
clientName: q.client.name,
totalValue: q.setupTotal + q.monthlyTotal,
status: q.status,
}))
.sort((a, b) => b.totalValue - a.totalValue)
.slice(0, 5)
const proposalQuotesTotal: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(SUM("setupTotal"), 0) as total
FROM "Quote"
WHERE "userId" = $1 AND status IN ('DRAFT', 'PROPOSAL', 'SENT')
`, user.id)
const totalProposalValue = Number(proposalQuotesTotal[0]?.total || 0)
const unpaidSales: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(SUM("totalAmount"), 0) as total
FROM "Sale"
WHERE "userId" = $1 AND paid = false AND proposal = false
`, user.id)
const unpaidSalesAmount = Number(unpaidSales[0]?.total || 0)
const unpaidInvoices: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(SUM(inv.amount), 0) as total
FROM "Invoice" inv
JOIN "Contract" c ON c.id = inv."contractId"
WHERE c."userId" = $1 AND inv.emitted = true AND inv."paidAt" IS NULL
`, user.id)
const unpaidRevenue = unpaidSalesAmount + (Number(unpaidInvoices[0]?.total) || 0)
const allSalesForChart: any[] = await prisma.$queryRawUnsafe(`
SELECT date, "totalAmount", "totalCost"
FROM "Sale"
WHERE "userId" = $1 AND paid = true AND proposal = false
ORDER BY date ASC
`, user.id)
const allInvoicesForChart: any[] = await prisma.$queryRawUnsafe(`
SELECT inv.month, inv.amount
FROM "Invoice" inv
JOIN "Contract" c ON c.id = inv."contractId"
WHERE c."userId" = $1
`, user.id)
const invoicesByMonth: Record<string, number> = {}
for (const inv of allInvoicesForChart) {
const m = startOfMonth(new Date(inv.month))
const key = format(m, 'yyyy-MM-dd')
invoicesByMonth[key] = (invoicesByMonth[key] || 0) + Number(inv.amount)
}
const newClientsByMonth: any[] = await prisma.$queryRawUnsafe(`
SELECT DATE_TRUNC('month', c."startDate") as month, COUNT(DISTINCT c."clientId") as count
FROM "Contract" c
WHERE c."userId" = $1
GROUP BY DATE_TRUNC('month', c."startDate")
ORDER BY month ASC
`, user.id)
const newClientsMap: Record<string, number> = {}
for (const row of newClientsByMonth) {
const m = startOfMonth(new Date(row.month))
const key = format(m, 'yyyy-MM-dd')
newClientsMap[key] = Number(row.count)
}
const chartData = months.map((month) => {
const mStart = startOfMonth(month)
const mEnd = endOfMonth(month)
let recurringRevenue = 0
let recurringCost = 0
let oneTimeRevenue = 0
let oneTimeCost = 0
let invoiceRevenue = 0
const monthKey = format(month, 'yyyy-MM-dd')
invoiceRevenue = invoicesByMonth[monthKey] || 0
const newClients = newClientsMap[monthKey] || 0
for (const ct of allContractsForChart) {
if (ct.status === 'ACTIVE' && ct.startDate <= mEnd && (!ct.endDate || ct.endDate >= mStart)) {
recurringRevenue += ct.monthlyPrice
recurringCost += ct.monthlyCost
}
if (ct.startDate >= mStart && ct.startDate <= mEnd) {
oneTimeRevenue += ct.setupPrice
oneTimeCost += ct.setupCost
}
}
for (const s of allSalesForChart) {
if (s.date >= mStart && s.date <= mEnd) {
oneTimeRevenue += s.totalAmount
oneTimeCost += s.totalCost
}
}
return {
month: format(month, 'MMM', { locale: fr }),
monthLabel: format(month, 'MMMM yyyy', { locale: fr }),
recurringRevenue,
recurringCost,
oneTimeRevenue,
oneTimeCost,
invoiceRevenue,
newClients,
revenue: recurringRevenue + oneTimeRevenue,
cost: recurringCost + oneTimeCost + (serverMonthlyCost / 12),
margin: (recurringRevenue + oneTimeRevenue) - (recurringCost + oneTimeCost + (serverMonthlyCost / 12)),
}
})
const yearlyCA = chartData.reduce((sum, m) => sum + m.revenue, 0)
return NextResponse.json({
stats: {
yearlyCA,
monthlyRecurringRevenue,
monthlyRecurringCost,
monthlyMargin,
monthlyMarginPercent,
invoicedThisMonth,
costThisMonth,
salesCostThisMonth,
marginThisMonth: (monthlyRecurringRevenue + invoicedThisMonth) - costThisMonth,
activeContracts: contracts.length,
activeClients,
prospectsCount,
topClients,
unpaidRevenue,
pendingProposalCount,
pendingProposalMonthly,
pausedCount,
pausedMonthly,
topQuotes: topQuotesSorted,
totalProposalValue,
projectedMonthlyRecurring: monthlyRecurringRevenue + pendingProposalMonthly + pausedMonthly,
projectedOneTimeSetup: 0,
projectedTotal: monthlyRecurringRevenue + pendingProposalMonthly + pausedMonthly,
serverMonthlyCost,
totalServerCostShared,
totalServerCostSharedRounded: Math.round(totalServerCostShared * 100) / 100,
},
chartData,
})
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { startOfMonth, eachMonthOfInterval, isBefore, isAfter } from 'date-fns'
export async function POST() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const contracts: any[] = await prisma.$queryRawUnsafe(`
SELECT id, "startDate", "endDate", "monthlyPrice" FROM "Contract"
WHERE "userId" = $1 AND status = 'ACTIVE'
`, user.id)
let created = 0
let skipped = 0
const today = startOfMonth(new Date())
for (const contract of contracts) {
const startMonth = startOfMonth(new Date(contract.startDate))
const endMonth = contract.endDate ? startOfMonth(new Date(contract.endDate)) : today
const months = eachMonthOfInterval({ start: startMonth, end: endMonth })
for (const month of months) {
if (isBefore(month, startMonth)) continue
if (isAfter(month, today)) continue
const existing: any[] = await prisma.$queryRawUnsafe(`
SELECT id FROM "Invoice"
WHERE "contractId" = $1 AND month = $2
`, contract.id, month)
if (existing.length > 0) {
skipped++
continue
}
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "Invoice" (id, "contractId", month, amount, "updatedAt")
VALUES ($1, $2, $3, $4, $5)
`, id, contract.id, month, contract.monthlyPrice, new Date())
created++
}
}
return NextResponse.json({ created, skipped })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const result: any[] = await prisma.$queryRawUnsafe(`
SELECT COUNT(*)::int as count FROM "Invoice" inv
JOIN "Contract" c ON c.id = inv."contractId"
WHERE c."userId" = $1
AND (inv."invoiceNumber" IS NULL OR inv."invoiceNumber" = ''
OR inv.emitted IS NOT TRUE
OR inv."paidAt" IS NULL)
`, user.id)
const count = result[0]?.count ?? 0
return NextResponse.json({ count })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const rows: any[] = await prisma.$queryRawUnsafe(`
SELECT inv.id, inv.month, inv.amount, inv."invoiceNumber",
inv.emitted, inv."emittedAt", inv."paidAt",
c.id as "contractId", c.name as "contractName",
cl.id as "clientId", cl.name as "clientName"
FROM "Invoice" inv
JOIN "Contract" c ON c.id = inv."contractId"
JOIN "Client" cl ON cl.id = c."clientId"
WHERE c."userId" = $1
ORDER BY inv.month DESC, cl.name ASC
`, user.id)
const invoices = rows.map((r: any) => ({
id: r.id,
month: r.month instanceof Date ? r.month.toISOString() : r.month,
amount: r.amount,
invoiceNumber: r.invoiceNumber,
emitted: r.emitted,
emittedAt: r.emittedAt instanceof Date ? r.emittedAt.toISOString() : r.emittedAt,
paidAt: r.paidAt instanceof Date ? r.paidAt.toISOString() : r.paidAt,
contract: {
id: r.contractId,
name: r.contractName,
client: { id: r.clientId, name: r.clientName },
},
}))
return NextResponse.json({ invoices })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PATCH(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const { id, invoiceNumber, emitted, paidAt } = data
if (invoiceNumber !== undefined) {
await prisma.$executeRawUnsafe(
`UPDATE "Invoice" SET "invoiceNumber" = $1 FROM "Contract" c WHERE "Invoice"."contractId" = c.id AND "Invoice".id = $2 AND c."userId" = $3`,
invoiceNumber || null, id, user.id
)
}
if (emitted !== undefined) {
if (emitted) {
await prisma.$executeRawUnsafe(
`UPDATE "Invoice" SET emitted = $1, "emittedAt" = NOW() FROM "Contract" c WHERE "Invoice"."contractId" = c.id AND "Invoice".id = $2 AND c."userId" = $3`,
emitted, id, user.id
)
} else {
await prisma.$executeRawUnsafe(
`UPDATE "Invoice" SET emitted = $1 FROM "Contract" c WHERE "Invoice"."contractId" = c.id AND "Invoice".id = $2 AND c."userId" = $3`,
emitted, id, user.id
)
}
}
if (paidAt !== undefined) {
const paidAtDate = paidAt ? new Date(paidAt + 'T00:00:00') : null
await prisma.$executeRawUnsafe(
`UPDATE "Invoice" SET "paidAt" = $1 FROM "Contract" c WHERE "Invoice"."contractId" = c.id AND "Invoice".id = $2 AND c."userId" = $3`,
paidAtDate, id, user.id
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID requis' }, { status: 400 })
const result = await prisma.$executeRawUnsafe(
`DELETE FROM "Invoice" USING "Contract" c WHERE "Invoice"."contractId" = c.id AND "Invoice".id = $1 AND c."userId" = $2`,
id, user.id
)
if (result === 0) {
return NextResponse.json({ error: 'Facture non trouvée' }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const quote = await prisma.quote.findFirst({
where: { id: params.id, userId: user.id },
include: { items: true, client: true },
})
if (!quote) return NextResponse.json({ error: 'Devis non trouvé' }, { status: 404 })
return NextResponse.json({ quote })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PUT(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
await prisma.quoteItem.deleteMany({ where: { quoteId: params.id } })
const quote = await prisma.quote.updateMany({
where: { id: params.id, userId: user.id },
data: {
clientId: data.clientId,
name: data.name,
quoteNumber: data.quoteNumber || null,
status: data.status,
setupTotal: data.setupTotal || 0,
monthlyTotal: data.monthlyTotal || 0,
costTotal: data.costTotal || 0,
marginTotal: data.marginTotal || 0,
discount: data.discount || 0,
travelKm: data.travelKm || 0,
travelCost: data.travelCost || 0,
notes: data.notes || null,
},
})
if (quote.count === 0) return NextResponse.json({ error: 'Devis non trouvé' }, { status: 404 })
if (data.items) {
for (const item of data.items) {
await prisma.quoteItem.create({
data: {
quoteId: params.id,
type: item.type,
label: item.label,
description: item.description || null,
unitPrice: item.unitPrice || 0,
costPrice: item.costPrice || 0,
quantity: item.quantity || 1,
isRecurring: item.isRecurring || false,
setupPrice: item.setupPrice || 0,
setupCost: item.setupCost || 0,
monthlyPrice: item.monthlyPrice || 0,
monthlyCost: item.monthlyCost || 0,
},
})
}
}
const updated = await prisma.quote.findUnique({
where: { id: params.id },
include: { items: true, client: true },
})
return NextResponse.json({ quote: updated })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const quote = await prisma.quote.deleteMany({
where: { id: params.id, userId: user.id },
})
if (quote.count === 0) return NextResponse.json({ error: 'Devis non trouvé' }, { status: 404 })
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const quotes = await prisma.quote.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
include: {
client: { select: { id: true, name: true } },
items: true,
},
})
return NextResponse.json({ quotes })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const quote = await prisma.quote.create({
data: {
clientId: data.clientId,
userId: user.id,
name: data.name,
quoteNumber: data.quoteNumber || null,
status: 'DRAFT',
setupTotal: data.setupTotal || 0,
monthlyTotal: data.monthlyTotal || 0,
costTotal: data.costTotal || 0,
marginTotal: data.marginTotal || 0,
discount: data.discount || 0,
travelKm: data.travelKm || 0,
travelCost: data.travelCost || 0,
notes: data.notes || null,
items: {
create: (data.items || []).map((item: any) => ({
type: item.type,
label: item.label,
description: item.description || null,
unitPrice: item.unitPrice || 0,
costPrice: item.costPrice || 0,
quantity: item.quantity || 1,
isRecurring: item.isRecurring || false,
setupPrice: item.setupPrice || 0,
setupCost: item.setupCost || 0,
monthlyPrice: item.monthlyPrice || 0,
monthlyCost: item.monthlyCost || 0,
})),
},
},
include: { items: true, client: true },
})
return NextResponse.json({ quote }, { status: 201 })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+131
View File
@@ -0,0 +1,131 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const sale = await prisma.sale.findFirst({
where: { id: params.id, userId: user.id },
include: { items: true, client: true },
})
if (!sale) return NextResponse.json({ error: 'Vente non trouvée' }, { status: 404 })
return NextResponse.json({ sale })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PUT(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
let clientId = data.clientId
if (!clientId && data.clientName) {
const client = await prisma.client.create({
data: {
name: data.clientName,
email: data.clientEmail || null,
phone: data.clientPhone || null,
address: data.clientAddress || null,
siret: data.clientSiret || null,
isProfessional: data.isProfessional ?? true,
type: 'CLIENT',
userId: user.id,
},
})
clientId = client.id
}
const updateData: any = {}
if (data.paid !== undefined) updateData.paid = data.paid
if (data.clientId !== undefined || data.clientName) updateData.clientId = clientId
if (data.date) updateData.date = new Date(data.date)
if (data.totalAmount !== undefined) updateData.totalAmount = data.totalAmount
if (data.totalCost !== undefined) updateData.totalCost = data.totalCost
if (data.notes !== undefined) updateData.notes = data.notes
const sale = await prisma.sale.updateMany({
where: { id: params.id, userId: user.id },
data: updateData,
})
if (sale.count === 0) return NextResponse.json({ error: 'Vente non trouvée' }, { status: 404 })
if (data.paid !== undefined) {
await prisma.$executeRawUnsafe(`UPDATE "Sale" SET paid = $1 WHERE id = $2`, data.paid, params.id)
}
if (data.proposal !== undefined) {
await prisma.$executeRawUnsafe(`UPDATE "Sale" SET proposal = $1 WHERE id = $2`, data.proposal, params.id)
}
if (data.items) {
await prisma.$executeRawUnsafe(`DELETE FROM "SaleItem" WHERE "saleId" = $1`, params.id)
const now = new Date()
for (const item of data.items) {
const itemId = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "SaleItem" (id, "saleId", label, description, quantity, "unitPrice", "costPrice", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, itemId, params.id, item.label, item.description || null, item.quantity || 1, item.unitPrice || 0, item.costPrice || 0, now)
}
}
const [updated]: any[] = await prisma.$queryRawUnsafe(`
SELECT s.id, s.paid, s.proposal, s."totalAmount", s."totalCost", s.date, s.notes,
c.id as "clientId", c.name as "clientName"
FROM "Sale" s
JOIN "Client" c ON c.id = s."clientId"
WHERE s.id = $1
`, params.id)
const updatedItems: any[] = await prisma.$queryRawUnsafe(`
SELECT id, label, description, quantity, "unitPrice", "costPrice"
FROM "SaleItem"
WHERE "saleId" = $1
ORDER BY "createdAt" ASC
`, params.id)
return NextResponse.json({
sale: {
id: updated.id,
client: { id: updated.clientId, name: updated.clientName },
date: updated.date,
totalAmount: updated.totalAmount,
totalCost: updated.totalCost,
paid: updated.paid,
proposal: updated.proposal,
notes: updated.notes,
items: updatedItems.map((i: any) => ({
id: i.id, label: i.label, description: i.description,
quantity: i.quantity, unitPrice: i.unitPrice, costPrice: i.costPrice,
})),
},
})
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const sale = await prisma.sale.deleteMany({
where: { id: params.id, userId: user.id },
})
if (sale.count === 0) return NextResponse.json({ error: 'Vente non trouvée' }, { status: 404 })
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const count = await prisma.sale.count({
where: { userId: user.id, paid: false },
})
return NextResponse.json({ count })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+131
View File
@@ -0,0 +1,131 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
function makeId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
}
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const salesRaw: any[] = await prisma.$queryRawUnsafe(`
SELECT s.id, s.paid, s.proposal, s."totalAmount", s."totalCost", s.date, s.notes, s."createdAt", s."updatedAt",
c.id as "clientId", c.name as "clientName"
FROM "Sale" s
JOIN "Client" c ON c.id = s."clientId"
WHERE s."userId" = $1
ORDER BY s.date DESC
`, user.id)
const itemsRaw: any[] = await prisma.$queryRawUnsafe(`
SELECT si.id, si."saleId", si.label, si.description, si.quantity, si."unitPrice", si."costPrice"
FROM "SaleItem" si
JOIN "Sale" s ON s.id = si."saleId"
WHERE s."userId" = $1
ORDER BY si."createdAt" ASC
`, user.id)
const itemsBySale: Record<string, any[]> = {}
for (const item of itemsRaw) {
if (!itemsBySale[item.saleId]) itemsBySale[item.saleId] = []
itemsBySale[item.saleId].push({
id: item.id,
label: item.label,
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
costPrice: item.costPrice,
})
}
const sales = salesRaw.map((s) => ({
id: s.id,
client: { id: s.clientId, name: s.clientName },
date: s.date,
totalAmount: s.totalAmount,
totalCost: s.totalCost,
paid: s.paid,
proposal: s.proposal,
notes: s.notes,
items: itemsBySale[s.id] || [],
}))
return NextResponse.json({ sales })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
let clientId = data.clientId
if (!clientId) {
const client = await prisma.client.create({
data: {
name: data.clientName,
email: data.clientEmail || null,
phone: data.clientPhone || null,
address: data.clientAddress || null,
siret: data.clientSiret || null,
isProfessional: data.isProfessional ?? true,
type: 'CLIENT',
userId: user.id,
},
})
clientId = client.id
}
const now = new Date()
const saleId = makeId()
const saleDate = data.date ? new Date(data.date) : now
await prisma.$executeRawUnsafe(`
INSERT INTO "Sale" (id, "clientId", "userId", date, "totalAmount", "totalCost", paid, proposal, notes, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, saleId, clientId, user.id, saleDate, data.totalAmount || 0, data.totalCost || 0,
data.paid === true, data.proposal === true, data.notes || null, now, now)
const items = data.items || []
for (const item of items) {
await prisma.$executeRawUnsafe(`
INSERT INTO "SaleItem" (id, "saleId", label, description, quantity, "unitPrice", "costPrice", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, makeId(), saleId, item.label, item.description || null, item.quantity || 1, item.unitPrice || 0, item.costPrice || 0, now)
}
const client: any[] = await prisma.$queryRawUnsafe(`
SELECT id, name FROM "Client" WHERE id = $1
`, clientId)
return NextResponse.json({
sale: {
id: saleId,
client: client[0] || { id: clientId, name: '' },
date: saleDate,
totalAmount: data.totalAmount || 0,
totalCost: data.totalCost || 0,
paid: data.paid === true,
proposal: data.proposal === true,
notes: data.notes || null,
items: items.map((i: any) => ({
label: i.label,
description: i.description || null,
quantity: i.quantity || 1,
unitPrice: i.unitPrice || 0,
costPrice: i.costPrice || 0,
})),
},
}, { status: 201 })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
const metrics: any[] = await prisma.$queryRawUnsafe(`
SELECT * FROM "ServerMetric"
WHERE "serverId" = $1
ORDER BY "timestamp" DESC
LIMIT 60
`, params.id)
return NextResponse.json(metrics)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request, { params }: { params: { id: string } }) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get('token')
const expectedToken = process.env.SERVER_MONITOR_SECRET
if (expectedToken && token !== expectedToken) {
return NextResponse.json({ error: 'Non autoris??' }, { status: 401 })
}
const body = await request.json()
const metricId = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "ServerMetric" (id, "serverId", "cpuUsage", "ramUsage", "diskUsage", uptime, "timestamp")
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, metricId, params.id,
body.cpuUsage != null ? Number(body.cpuUsage) : null,
body.ramUsage != null ? Number(body.ramUsage) : null,
body.diskUsage != null ? Number(body.diskUsage) : null,
body.uptime != null ? BigInt(body.uptime) : null)
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
const servers: any[] = await prisma.$queryRawUnsafe(`
SELECT s.*,
(SELECT json_agg(json_build_object(
'cpuUsage', sm."cpuUsage",
'ramUsage', sm."ramUsage",
'diskUsage', sm."diskUsage",
'uptime', sm.uptime,
'timestamp', sm."timestamp"
) ORDER BY sm."timestamp" DESC) FROM "ServerMetric" sm
WHERE sm."serverId" = s.id
LIMIT 60) as metrics
FROM "Server" s
WHERE s.id = $1 AND s."userId" = $2
`, params.id, user.id)
if (!servers.length) {
return NextResponse.json({ error: 'Serveur introuvable' }, { status: 404 })
}
return NextResponse.json(servers[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
const body = await request.json()
const now = new Date()
const fields: string[] = []
const values: any[] = []
let idx = 1
for (const key of ['name', 'ip', 'os', 'cpu', 'ram', 'disk', 'status', 'notes']) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(body[key])
}
}
for (const key of ['monthlyCost']) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(Number(body[key]))
}
}
for (const key of ['startDate', 'endDate']) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(body[key] ? new Date(body[key]) : null)
}
}
if (fields.length === 0) {
return NextResponse.json({ error: 'Aucun champ ?? mettre ?? jour' }, { status: 400 })
}
fields.push(`"updatedAt" = $${idx++}`)
values.push(now)
values.push(params.id)
values.push(user.id)
await prisma.$executeRawUnsafe(`
UPDATE "Server" SET ${fields.join(', ')}
WHERE id = $${idx} AND "userId" = $${idx + 1}
`, ...values)
const updated: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "Server" WHERE id = $1`, params.id)
return NextResponse.json(updated[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
await prisma.$executeRawUnsafe(`
DELETE FROM "ServerMetric" WHERE "serverId" = $1
`, params.id)
await prisma.$executeRawUnsafe(`
DELETE FROM "Server" WHERE id = $1 AND "userId" = $2
`, params.id, user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
@@ -0,0 +1,164 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
export async function POST(request: Request, { params }: { params: { id: string } }) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get('token')
const expectedToken = process.env.SERVER_MONITOR_SECRET
const useToken = expectedToken && token === expectedToken
if (!useToken) {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
}
const serverCheck: any[] = await prisma.$queryRawUnsafe(`SELECT id, name FROM "Server" WHERE id = $1`, params.id)
if (!serverCheck.length) return NextResponse.json({ error: 'Serveur introuvable' }, { status: 404 })
const discovered: any[] = []
// 1. Systemd services
try {
const { stdout } = await execAsync(
`systemctl list-units --type=service --all --no-legend --no-pager 2>/dev/null | head -50`
)
for (const line of stdout.trim().split('\n')) {
const parts = line.trim().split(/\s+/)
if (parts.length < 4) continue
const name = parts[0].replace('.service', '')
const status = parts[3] === 'running' ? 'running' : parts[3] === 'exited' ? 'stopped' : 'error'
const pid = parts[3] === 'running' ? 1 : null
discovered.push({
name,
type: 'service',
status,
processName: parts[0],
technology: 'systemd',
})
}
} catch {}
// 2. Docker containers
try {
const { stdout } = await execAsync(`docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>/dev/null | head -30`)
for (const line of stdout.trim().split('\n')) {
if (!line.trim()) continue
const parts = line.trim().split('\t')
if (parts.length < 2) continue
const name = parts[0]
const image = parts[1]
const statusText = parts[2] || ''
const ports = parts[3] || ''
const isRunning = statusText.toLowerCase().includes('up')
const port = ports.match(/(\d+)->/) ? parseInt(ports.match(/(\d+)->/)![1]) : null
discovered.push({
name: `Docker: ${name}`,
type: 'container',
status: isRunning ? 'running' : 'stopped',
port,
technology: `Docker (${image})`,
processName: `docker:${name}`,
})
}
} catch {}
// 3. Processes listening on ports
try {
const { stdout } = await execAsync(`ss -tlnp4 2>/dev/null | tail -n +2 | head -30`)
for (const line of stdout.trim().split('\n')) {
if (!line.trim()) continue
const parts = line.trim().split(/\s+/)
if (parts.length < 5) continue
const addr = parts[4]
const port = addr.includes(':') ? parseInt(addr.split(':').pop()!) : null
const processInfo = parts[5] || ''
const procMatch = processInfo.match(/users:\(\("([^"]+)/)
const processName = procMatch ? procMatch[1] : 'unknown'
if (port && port < 9000 && ![3005, 5432, 3306, 8082, 8086, 8096].includes(port)) {
discovered.push({
name: `Port ${port}`,
type: 'port',
status: 'running',
port,
processName,
technology: 'TCP',
})
}
}
} catch {}
// 4. Nginx sites
try {
const { stdout } = await execAsync(`ls /etc/nginx/sites-enabled/ 2>/dev/null`)
for (const site of stdout.trim().split('\n')) {
if (!site.trim()) continue
const { stdout: port } = await execAsync(
`grep -E 'listen|proxy_pass|server_name' /etc/nginx/sites-enabled/${site} 2>/dev/null | head -5`
)
discovered.push({
name: `Nginx: ${site}`,
type: 'website',
status: 'running',
technology: `nginx\n${port}`,
processName: 'nginx',
})
}
} catch {}
// Insert discovered services
const now = new Date()
let insertedCount = 0
const discoveredNames = new Set(discovered.map(s => s.name))
for (const svc of discovered) {
const existing: any[] = await prisma.$queryRawUnsafe(`
SELECT id FROM "Service" WHERE "serverId" = $1 AND name = $2
`, params.id, svc.name)
if (existing.length > 0) {
await prisma.$executeRawUnsafe(`
UPDATE "Service" SET status = $1, port = $2, technology = $3, "processName" = $4, "updatedAt" = $5
WHERE id = $6
`, svc.status, svc.port || null, svc.technology || '', svc.processName || '', now, existing[0].id)
} else {
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "Service" (id, "serverId", name, type, port, technology, status, "processName", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, id, params.id, svc.name, svc.type, svc.port || null, svc.technology || '', svc.status, svc.processName || '', now, now)
insertedCount++
}
}
// Delete stale services that no longer exist on the server
let deleted = 0
if (discoveredNames.size > 0) {
const namesArr = Array.from(discoveredNames)
const placeholders = namesArr.map((_, i) => `$${i + 2}`).join(', ')
await prisma.$executeRawUnsafe(`
DELETE FROM "Service"
WHERE "serverId" = $1 AND "processName" IS NOT NULL AND "processName" != '' AND name NOT IN (${placeholders})
`, params.id, ...namesArr)
}
return NextResponse.json({
success: true,
discovered: discovered.length,
inserted: insertedCount,
deleted,
total: discovered.length,
})
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
const services: any[] = await prisma.$queryRawUnsafe(`
SELECT * FROM "Service"
WHERE "serverId" = $1
ORDER BY type ASC, name ASC
`, params.id)
return NextResponse.json(services)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const servers: any[] = await prisma.$queryRawUnsafe(`
SELECT s.*,
(SELECT json_build_object(
'cpuUsage', sm."cpuUsage",
'ramUsage', sm."ramUsage",
'diskUsage', sm."diskUsage",
'uptime', sm.uptime,
'timestamp', sm."timestamp"
) FROM "ServerMetric" sm
WHERE sm."serverId" = s.id
ORDER BY sm."timestamp" DESC LIMIT 1) as latest_metric
FROM "Server" s
WHERE s."userId" = $1
ORDER BY s.name ASC
`, user.id)
const totalServers = servers.length
const activeServers = servers.filter((s: any) => s.status === 'ACTIVE').length
const totalMonthlyCost = servers.reduce((sum: number, s: any) => sum + Number(s.monthlyCost || 0), 0)
const onlineServers = servers.filter((s: any) => s.latest_metric !== null).length
return NextResponse.json({
servers,
stats: {
totalServers,
activeServers,
totalMonthlyCost,
onlineServers,
offlineServers: activeServers - onlineServers,
},
})
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const servers: any[] = await prisma.$queryRawUnsafe(`
SELECT s.*,
(SELECT json_build_object(
'cpuUsage', sm."cpuUsage",
'ramUsage', sm."ramUsage",
'diskUsage', sm."diskUsage",
'uptime', sm.uptime,
'timestamp', sm."timestamp"
) FROM "ServerMetric" sm
WHERE sm."serverId" = s.id
ORDER BY sm."timestamp" DESC LIMIT 1) as latest_metric
FROM "Server" s
WHERE s."userId" = $1
ORDER BY s.name ASC
`, user.id)
const parsed = servers.map((s: any) => ({
...s,
latest_metric: s.latest_metric || null,
}))
return NextResponse.json(parsed)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const body = await request.json()
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
const now = new Date()
const { name, ip, os, cpu, ram, disk, startDate, endDate, monthlyCost, status, notes } = body
await prisma.$executeRawUnsafe(`
INSERT INTO "Server" (id, "userId", name, ip, os, cpu, ram, disk, "startDate", "endDate", "monthlyCost", status, notes, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`, id, user.id, name || '', ip || '', os || '', cpu || '', ram || '', disk || '',
startDate ? new Date(startDate) : null, endDate ? new Date(endDate) : null,
monthlyCost ? Number(monthlyCost) : 0, status || 'ACTIVE', notes || '', now, now)
const created: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "Server" WHERE id = $1`, id)
return NextResponse.json(created[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
const body = await request.json()
const now = new Date()
const fields: string[] = []
const values: any[] = []
let idx = 1
for (const key of ['name', 'type', 'technology', 'url', 'status', 'notes', 'processName']) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(body[key])
}
}
for (const key of ['port', 'pid']) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(body[key] != null ? Number(body[key]) : null)
}
}
for (const key of ['cpuUsage', 'ramUsage', 'diskUsage']) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(body[key] != null ? Number(body[key]) : null)
}
}
if (fields.length === 0) {
return NextResponse.json({ error: 'Aucun champ ?? mettre ?? jour' }, { status: 400 })
}
fields.push(`"updatedAt" = $${idx++}`)
values.push(now)
values.push(params.id)
values.push(user.id)
await prisma.$executeRawUnsafe(`
UPDATE "Service" SET ${fields.join(', ')}
WHERE id = $${idx} AND "serverId" IN (SELECT id FROM "Server" WHERE "userId" = $${idx + 1})
`, ...values)
const updated: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "Service" WHERE id = $1`, params.id)
return NextResponse.json(updated[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifi??' }, { status: 401 })
await prisma.$executeRawUnsafe(`
DELETE FROM "Service" WHERE id = $1 AND "serverId" IN (SELECT id FROM "Server" WHERE "userId" = $2)
`, params.id, user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const services: any[] = await prisma.$queryRawUnsafe(`
SELECT sv.*, s.name as server_name, s.ip as server_ip
FROM "Service" sv
JOIN "Server" s ON s.id = sv."serverId"
WHERE s."userId" = $1
ORDER BY s.name ASC, sv.name ASC
`, user.id)
return NextResponse.json(services)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const body = await request.json()
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
const now = new Date()
const { serverId, name, type, port, technology, url, status, cpuUsage, ramUsage, diskUsage, pid, processName, notes } = body
const serverCheck: any[] = await prisma.$queryRawUnsafe(`SELECT id FROM "Server" WHERE id = $1 AND "userId" = $2`, serverId, user.id)
if (!serverCheck.length) return NextResponse.json({ error: 'Serveur introuvable' }, { status: 404 })
await prisma.$executeRawUnsafe(`
INSERT INTO "Service" (id, "serverId", name, type, port, technology, url, status, "cpuUsage", "ramUsage", "diskUsage", pid, "processName", notes, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`, id, serverId, name, type || 'service', port ? Number(port) : null, technology || '', url || '',
status || 'running', cpuUsage != null ? Number(cpuUsage) : null, ramUsage != null ? Number(ramUsage) : null,
diskUsage != null ? Number(diskUsage) : null, pid ? Number(pid) : null, processName || '', notes || '', now, now)
const created: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "Service" WHERE id = $1`, id)
return NextResponse.json(created[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const DEFAULT_PRODUCTS = [
{ key: 'maintenance-poste', label: 'Maintenance Poste (Windows/Mac)', costPrice: 1.40, sellPrice: 9.60, unit: 'mois', category: 'maintenance' },
{ key: 'maintenance-serveur', label: 'Maintenance Serveur', costPrice: 4.20, sellPrice: 25.20, unit: 'mois', category: 'maintenance' },
{ key: 'ksuite-business', label: 'KSuite Business / utilisateur', costPrice: 6.58, sellPrice: 15.80, unit: 'mois', category: 'ksuite' },
{ key: 'ksuite-standard', label: 'KSuite Standard / utilisateur', costPrice: 1.58, sellPrice: 0, unit: 'mois', category: 'ksuite' },
{ key: 'ksuite-entreprise', label: 'KSuite Entreprise / utilisateur', costPrice: 12.41, sellPrice: 0, unit: 'mois', category: 'ksuite' },
{ key: 'intervention-horaire', label: 'Intervention (Taux horaire)', costPrice: 0, sellPrice: 50, unit: 'heure', category: 'hourly' },
{ key: 'deplacement-km', label: 'Déplacement (par km)', costPrice: 0.30, sellPrice: 0.50, unit: 'km', category: 'travel' },
]
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const products = await prisma.productCost.findMany({
where: { isActive: true },
orderBy: { category: 'asc' },
})
return NextResponse.json({ products })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PUT(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const product = await prisma.productCost.update({
where: { id: data.id },
data: {
costPrice: data.costPrice,
sellPrice: data.sellPrice,
label: data.label,
isActive: data.isActive,
},
})
return NextResponse.json({ product })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
for (const product of DEFAULT_PRODUCTS) {
await prisma.productCost.upsert({
where: { key: product.key },
update: product,
create: product,
})
}
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string, attachmentId: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const check: any[] = await prisma.$queryRawUnsafe(`SELECT id FROM "Ticket" WHERE id = $1 AND "userId" = $2`, params.id, user.id)
if (!check.length) return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
const att: any[] = await prisma.$queryRawUnsafe(`
SELECT * FROM "TicketAttachment" WHERE id = $1 AND "ticketId" = $2
`, params.attachmentId, params.id)
if (!att.length) return NextResponse.json({ error: 'Fichier introuvable' }, { status: 404 })
const buffer = Buffer.from(att[0].data)
return new NextResponse(buffer, {
headers: {
'Content-Type': att[0].contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const check: any[] = await prisma.$queryRawUnsafe(`SELECT id FROM "Ticket" WHERE id = $1 AND "userId" = $2`, params.id, user.id)
if (!check.length) return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
const attachments: any[] = await prisma.$queryRawUnsafe(`
SELECT id, filename, "contentType", "contentId", "messageId", "createdAt"
FROM "TicketAttachment"
WHERE "ticketId" = $1
ORDER BY "createdAt" ASC
`, params.id)
return NextResponse.json(attachments)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { sendEmail } from '@/lib/mail'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const check: any[] = await prisma.$queryRawUnsafe(`SELECT id FROM "Ticket" WHERE id = $1 AND "userId" = $2`, params.id, user.id)
if (!check.length) return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
const messages: any[] = await prisma.$queryRawUnsafe(`
SELECT * FROM "TicketMessage"
WHERE "ticketId" = $1
ORDER BY "createdAt" ASC
`, params.id)
return NextResponse.json(messages)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const tickets: any[] = await prisma.$queryRawUnsafe(`
SELECT id, source, "emailFrom", subject FROM "Ticket" WHERE id = $1 AND "userId" = $2
`, params.id, user.id)
if (!tickets.length) return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
const ticket = tickets[0]
const body = await request.json()
const msgId = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
const now = new Date()
await prisma.$executeRawUnsafe(`
INSERT INTO "TicketMessage" (id, "ticketId", content, author, "isInternal", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6)
`, msgId, params.id, body.content, body.author || user.name || 'Moi', body.isInternal || false, now)
if (body.images && Array.isArray(body.images)) {
for (const img of body.images) {
if (!img.data) continue
const attId = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
try {
await prisma.$executeRawUnsafe(`
INSERT INTO "TicketAttachment" (id, "ticketId", "messageId", filename, "contentType", data, "contentId", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, attId, params.id, msgId, img.filename || 'image.png', img.contentType || 'image/png', Buffer.from(img.data, 'base64'), null, now)
} catch (attErr) {
console.error('Erreur sauvegarde image:', attErr)
}
}
}
await prisma.$executeRawUnsafe(`
UPDATE "Ticket" SET "updatedAt" = $1 WHERE id = $2
`, now, params.id)
if (ticket.source === 'email' && ticket.emailFrom && !body.isInternal) {
try {
await sendEmail(
ticket.emailFrom,
`Re: ${ticket.subject}`,
body.content
)
} catch (mailErr) {
console.error('Erreur envoi email:', mailErr)
}
}
const created: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "TicketMessage" WHERE id = $1`, msgId)
return NextResponse.json(created[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const tickets: any[] = await prisma.$queryRawUnsafe(`
SELECT t.*, c.name as client_name, c.name as client_company, c.email as client_email,
s.name as server_name, s.ip as server_ip
FROM "Ticket" t
LEFT JOIN "Client" c ON c.id = t."clientId"
LEFT JOIN "Server" s ON s.id = t."serverId"
WHERE t.id = $1 AND t."userId" = $2
`, params.id, user.id)
if (!tickets.length) {
return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
}
const results = await Promise.all([
prisma.$queryRawUnsafe(`
SELECT * FROM "TicketMessage"
WHERE "ticketId" = $1
ORDER BY "createdAt" ASC
`, params.id),
prisma.$queryRawUnsafe(`
SELECT id, filename, "contentType", "contentId", "messageId", "createdAt"
FROM "TicketAttachment"
WHERE "ticketId" = $1
ORDER BY "createdAt" ASC
`, params.id),
])
const messages = results[0] as any[]
const attachments = results[1] as any[]
return NextResponse.json({ ...tickets[0], messages, attachments })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const body = await request.json()
const now = new Date()
const check: any[] = await prisma.$queryRawUnsafe(`SELECT id FROM "Ticket" WHERE id = $1 AND "userId" = $2`, params.id, user.id)
if (!check.length) return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
const allowed = ['status', 'priority', 'clientId', 'serverId', 'subject', 'description', 'assignedTo']
const fields: string[] = []
const values: any[] = []
let idx = 1
for (const key of allowed) {
if (body[key] !== undefined) {
fields.push(`"${key}" = $${idx++}`)
values.push(body[key])
}
}
if (fields.length === 0) {
return NextResponse.json({ error: 'Aucun champ à mettre à jour' }, { status: 400 })
}
fields.push(`"updatedAt" = $${idx++}`)
values.push(now)
values.push(params.id)
values.push(user.id)
await prisma.$executeRawUnsafe(`
UPDATE "Ticket" SET ${fields.join(', ')}
WHERE id = $${idx} AND "userId" = $${idx + 1}
`, ...values)
const updated: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "Ticket" WHERE id = $1`, params.id)
return NextResponse.json(updated[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const check: any[] = await prisma.$queryRawUnsafe(`SELECT id FROM "Ticket" WHERE id = $1 AND "userId" = $2`, params.id, user.id)
if (!check.length) return NextResponse.json({ error: 'Ticket introuvable' }, { status: 404 })
await prisma.$executeRawUnsafe(`DELETE FROM "TicketAttachment" WHERE "ticketId" = $1`, params.id)
await prisma.$executeRawUnsafe(`DELETE FROM "TicketMessage" WHERE "ticketId" = $1`, params.id)
await prisma.$executeRawUnsafe(`DELETE FROM "Ticket" WHERE id = $1`, params.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const total: any[] = await prisma.$queryRawUnsafe(`
SELECT COUNT(*)::int as count FROM "Ticket" WHERE "userId" = $1
`, user.id)
const byStatus: any[] = await prisma.$queryRawUnsafe(`
SELECT status, COUNT(*)::int as count FROM "Ticket"
WHERE "userId" = $1
GROUP BY status ORDER BY status
`, user.id)
const byPriority: any[] = await prisma.$queryRawUnsafe(`
SELECT priority, COUNT(*)::int as count FROM "Ticket"
WHERE "userId" = $1
GROUP BY priority ORDER BY priority
`, user.id)
const byClient: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(c.name, 'Sans client') as client, COUNT(*)::int as count
FROM "Ticket" t
LEFT JOIN "Client" c ON c.id = t."clientId"
WHERE t."userId" = $1
GROUP BY c.name
ORDER BY count DESC
`, user.id)
const monthly: any[] = await prisma.$queryRawUnsafe(`
SELECT to_char("createdAt", 'YYYY-MM') as month, COUNT(*)::int as count
FROM "Ticket"
WHERE "userId" = $1
GROUP BY month
ORDER BY month ASC
`, user.id)
const unresolved: any[] = await prisma.$queryRawUnsafe(`
SELECT COUNT(*)::int as count FROM "Ticket"
WHERE "userId" = $1 AND status NOT IN ('resolved', 'closed')
`, user.id)
return NextResponse.json({
total: total[0]?.count || 0,
byStatus,
byPriority,
byClient,
monthly,
unresolved: unresolved[0]?.count || 0,
})
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
const EMAIL_SECRET = process.env.SERVER_MONITOR_SECRET || ''
export async function POST(request: Request) {
try {
const { searchParams } = new URL(request.url)
const token = searchParams.get('token')
if (token !== EMAIL_SECRET) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
}
const body = await request.json()
const { from, subject, text, html, attachments } = body
if (!from || !subject) {
return NextResponse.json({ error: 'Champs from et subject requis' }, { status: 400 })
}
const userRow: any[] = await prisma.$queryRawUnsafe(`
SELECT id FROM "User" LIMIT 1
`)
if (!userRow.length) {
return NextResponse.json({ error: 'Aucun utilisateur' }, { status: 500 })
}
const userId = userRow[0].id
const clientRow: any[] = await prisma.$queryRawUnsafe(`
SELECT id, name FROM "Client"
WHERE email = $1
LIMIT 1
`, from)
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
const now = new Date()
const maxRow: any[] = await prisma.$queryRawUnsafe(`SELECT COALESCE(MAX("ticketNumber"), 0) + 1 as next FROM "Ticket"`)
const ticketNumber = maxRow[0]?.next || 1
const msgId = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "Ticket" (id, "userId", "clientId", subject, description, status, priority, source, "emailFrom", "ticketNumber", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, 'new', 'normal', 'email', $6, $7, $8, $9)
`, id, userId, clientRow[0]?.id || null, subject, text || html || '', from, ticketNumber, now, now)
await prisma.$executeRawUnsafe(`
INSERT INTO "TicketMessage" (id, "ticketId", content, author, "isInternal", "createdAt")
VALUES ($1, $2, $3, $4, false, $5)
`, msgId, id, text || html || '', clientRow[0]?.name || from, now)
if (attachments && Array.isArray(attachments)) {
for (const att of attachments) {
if (!att.data) continue
const attId = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
try {
await prisma.$executeRawUnsafe(`
INSERT INTO "TicketAttachment" (id, "ticketId", "messageId", filename, "contentType", data, "contentId", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, attId, id, msgId, att.filename || 'image.png', att.contentType || 'image/png', Buffer.from(att.data, 'base64'), att.contentId || null, now)
} catch (attErr) {
console.error('Erreur sauvegarde pièce jointe:', attErr)
}
}
}
return NextResponse.json({ success: true, ticketId: id })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const clientId = searchParams.get('clientId')
let sql = `SELECT t.*, c.name as client_name, c.name as client_company, s.name as server_name
FROM "Ticket" t
LEFT JOIN "Client" c ON c.id = t."clientId"
LEFT JOIN "Server" s ON s.id = t."serverId"
WHERE t."userId" = $1`
const params: any[] = [user.id]
if (status) {
const statuses = status.split(',')
if (statuses.length === 1) {
sql += ` AND t.status = $${params.length + 1}`
params.push(status)
} else {
sql += ` AND t.status IN (${statuses.map((_, i) => `$${params.length + 1 + i}`).join(',')})`
params.push(...statuses)
}
}
if (clientId) {
sql += ` AND t."clientId" = $${params.length + 1}`
params.push(clientId)
}
sql += ' ORDER BY t."createdAt" DESC'
const tickets = await prisma.$queryRawUnsafe(sql, ...params)
return NextResponse.json(tickets)
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const body = await request.json()
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
const now = new Date()
const maxRow: any[] = await prisma.$queryRawUnsafe(`SELECT COALESCE(MAX("ticketNumber"), 0) + 1 as next FROM "Ticket"`)
const ticketNumber = maxRow[0]?.next || 1
const { clientId, serverId, subject, description, priority, source, emailFrom } = body
await prisma.$executeRawUnsafe(`
INSERT INTO "Ticket" (id, "userId", "clientId", "serverId", subject, description, status, priority, source, "emailFrom", "assignedTo", "ticketNumber", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, 'new', $7, $8, $9, $10, $11, $12, $13)
`, id, user.id, clientId || null, serverId || null, subject, description || '',
priority || 'normal', source || 'manual', emailFrom || null, null, ticketNumber, now, now)
const created: any[] = await prisma.$queryRawUnsafe(`SELECT * FROM "Ticket" WHERE id = $1`, id)
return NextResponse.json(created[0])
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const todos: any[] = await prisma.$queryRawUnsafe(`
SELECT id, text, position, done FROM "TodoItem"
WHERE "userId" = $1
ORDER BY position ASC
`, user.id)
return NextResponse.json({ todos })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const maxPos: any[] = await prisma.$queryRawUnsafe(`
SELECT COALESCE(MAX(position), -1) as pos FROM "TodoItem"
WHERE "userId" = $1
`, user.id)
const pos = (maxPos[0]?.pos ?? -1) + 1
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "TodoItem" (id, "userId", text, position, done, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, false, $5, $6)
`, id, user.id, data.text, pos, new Date(), new Date())
const todo = { id, text: data.text, position: pos, done: false }
return NextResponse.json({ todo }, { status: 201 })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function PATCH(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const data = await request.json()
const { id, text, done, position } = data
const check: any[] = await prisma.$queryRawUnsafe(`
SELECT id FROM "TodoItem" WHERE id = $1 AND "userId" = $2
`, id, user.id)
if (check.length === 0) {
return NextResponse.json({ error: 'Todo non trouvé' }, { status: 404 })
}
const sets: string[] = []
const params: any[] = []
let idx = 1
if (text !== undefined) { sets.push(`text = $${idx++}`); params.push(text) }
if (done !== undefined) { sets.push(`done = $${idx++}`); params.push(done) }
if (position !== undefined) { sets.push(`position = $${idx++}`); params.push(position) }
if (sets.length > 0) {
params.push(id)
await prisma.$executeRawUnsafe(`UPDATE "TodoItem" SET ${sets.join(', ')} WHERE id = $${idx}`, ...params)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(request: Request) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID requis' }, { status: 400 })
await prisma.$executeRawUnsafe(`
DELETE FROM "TodoItem" WHERE id = $1 AND "userId" = $2
`, id, user.id)
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
+45
View File
@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #f8fafc;
--foreground: #1e293b;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
--foreground: #f1f5f9;
}
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: #e2e8f0;
}
.dark .recharts-cartesian-grid-horizontal line,
.dark .recharts-cartesian-grid-vertical line {
stroke: #334155;
}
.recharts-tooltip-wrapper {
outline: none;
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'SecourIT - Gestion',
description: 'Application de gestion commerciale',
manifest: '/manifest.json',
icons: {
icon: '/favicon.svg?v=2',
apple: '/favicon.svg?v=2',
},
other: {
'theme-color': '#2563eb',
'apple-mobile-web-app-capable': 'yes',
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="fr">
<head>
<link rel="icon" href="/favicon.svg?v=2" />
<link rel="apple-touch-icon" href="/favicon.svg?v=2" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="SecourIT" />
<script dangerouslySetInnerHTML={{ __html: `
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
})
}
`}} />
</head>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import { useState, FormEvent } from 'react'
import { useAuth } from '@/contexts/AuthContext'
export default function LoginPage() {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
const fillDemo = () => {
setEmail('demo@secourit.fr')
setPassword('demo123')
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<img src="/logo.png" alt="SecourIT" className="h-10 mx-auto mb-3" />
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" required />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Mot de passe</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" required />
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm p-3 rounded-lg">{error}</div>
)}
<button type="submit" disabled={loading}
className="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50">
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
<div className="mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
<p className="text-xs text-slate-400 text-center mb-2">Créé par</p>
<a href="https://secourit.fr/" target="_blank" rel="noopener noreferrer"
className="block w-full text-center text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 font-medium py-2 px-4 rounded-lg border border-primary-200 dark:border-primary-800 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
Secour | IT
</a>
</div>
</div>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardClient } from '@/components/DashboardClient'
export default function Home() {
return <DashboardClient />
}
+8
View File
@@ -0,0 +1,8 @@
'use client'
import { AuthProvider } from '@/contexts/AuthContext'
import { ReactNode } from 'react'
export function Providers({ children }: { children: ReactNode }) {
return <AuthProvider>{children}</AuthProvider>
}
+513
View File
@@ -0,0 +1,513 @@
'use client'
import { useState, useEffect } from 'react'
import { Search, Plus, UserPlus, UserCheck, Edit2, Trash2, X, Eye, FileText, Calendar, Server } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
interface Client {
id: string
name: string
email: string | null
phone: string | null
address: string | null
siret: string | null
type: 'PROSPECT' | 'CLIENT'
notes: string | null
createdAt: string
contractSetup?: number
contractMonthly?: number
salesTotal?: number
totalInvoiced?: number
totalPaid?: number
totalUnpaid?: number
serverCount?: number
serverCostShare?: number
_count?: { quotes: number; contracts: number; sales: number }
}
export function ClientsContent() {
const [clients, setClients] = useState<Client[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'ALL' | 'PROSPECT' | 'CLIENT'>('ALL')
const [search, setSearch] = useState('')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<Client | null>(null)
const [form, setForm] = useState({ name: '', email: '', phone: '', address: '', siret: '', type: 'PROSPECT' as 'PROSPECT' | 'CLIENT', notes: '', serverIds: [] as string[] })
const [saving, setSaving] = useState(false)
const [viewingClient, setViewingClient] = useState<any>(null)
const [clientDetail, setClientDetail] = useState<any>(null)
const [clientServers, setClientServers] = useState<any[]>([])
const [loadingDetail, setLoadingDetail] = useState(false)
const [servers, setServers] = useState<{ id: string; name: string }[]>([])
useEffect(() => { fetchClients() }, [filter])
useEffect(() => {
fetch('/api/servers').then(r => r.ok && r.json()).then(d => setServers(d || [])).catch(() => {})
}, [])
const fetchClients = async () => {
setLoading(true)
try {
const params = filter !== 'ALL' ? `?type=${filter}` : ''
const res = await fetch(`/api/clients${params}`)
if (res.ok) {
const data = await res.json()
setClients(data.clients)
}
} finally {
setLoading(false)
}
}
const openNew = () => {
setEditing(null)
setForm({ name: '', email: '', phone: '', address: '', siret: '', type: 'PROSPECT', notes: '', serverIds: [] })
setShowForm(true)
}
const openEdit = async (client: Client) => {
setEditing(client)
setForm({ name: client.name, email: client.email || '', phone: client.phone || '', address: client.address || '', siret: client.siret || '', type: client.type, notes: client.notes || '', serverIds: [] })
try {
const r = await fetch(`/api/clients/${client.id}`)
if (r.ok) {
const d = await r.json()
if (d.servers) setForm(f => ({ ...f, serverIds: d.servers.map((s: any) => s.id) }))
}
} catch {}
setShowForm(true)
}
const handleSave = async () => {
if (!form.name.trim()) return
setSaving(true)
try {
if (editing) {
await fetch(`/api/clients/${editing.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, serverIds: form.serverIds }),
})
} else {
await fetch('/api/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, serverIds: form.serverIds }),
})
}
setShowForm(false)
setEditing(null)
fetchClients()
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer ce client ?')) return
try {
await fetch(`/api/clients/${id}`, { method: 'DELETE' })
fetchClients()
} catch {}
}
const handleView = async (client: Client) => {
setViewingClient(client)
setLoadingDetail(true)
setClientDetail(null)
setClientServers([])
try {
const res = await fetch(`/api/clients/${client.id}`)
if (res.ok) {
const data = await res.json()
setClientDetail(data.client)
setClientServers(data.servers || [])
}
} finally {
setLoadingDetail(false)
}
}
const handleConvertToClient = async (client: Client) => {
try {
await fetch(`/api/clients/${client.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...client, type: 'CLIENT' }),
})
fetchClients()
} catch {}
}
const filtered = clients.filter((c) =>
search === '' || c.name.toLowerCase().includes(search.toLowerCase()) || (c.email && c.email.toLowerCase().includes(search.toLowerCase()))
)
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Clients & Prospects</h2>
<button
onClick={openNew}
className="inline-flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Plus className="h-4 w-4" />
Nouveau client
</button>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Rechercher..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm"
/>
</div>
<div className="flex gap-2">
{(['ALL', 'PROSPECT', 'CLIENT'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-primary-600 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50'
}`}
>
{f === 'ALL' ? 'Tous' : f === 'PROSPECT' ? 'Prospects' : 'Clients'}
</button>
))}
</div>
</div>
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-lg p-6 shadow-xl">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{editing ? 'Modifier' : 'Nouveau'} client
</h3>
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Téléphone</label>
<input
type="text"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adresse</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">SIRET</label>
<input
type="text"
value={form.siret}
onChange={(e) => setForm({ ...form, siret: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value as 'PROSPECT' | 'CLIENT' })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
>
<option value="PROSPECT">Prospect</option>
<option value="CLIENT">Client</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Serveurs attribués</label>
<div className="space-y-1.5 max-h-32 overflow-y-auto border border-slate-300 dark:border-slate-600 rounded-lg p-2">
{servers.length === 0 ? (
<p className="text-xs text-slate-400">Aucun serveur disponible</p>
) : (
servers.map(s => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={form.serverIds.includes(s.id)}
onChange={e => setForm(f => ({
...f,
serverIds: e.target.checked ? [...f.serverIds, s.id] : f.serverIds.filter(id => id !== s.id)
}))}
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500" />
<Server className="h-3.5 w-3.5 text-slate-400" />
<span className="text-slate-700 dark:text-slate-300">{s.name}</span>
</label>
))
)}
</div>
<p className="text-xs text-slate-400 mt-1">Le coût du serveur est réparti entre tous les clients qui l'utilisent</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-lg disabled:opacity-50"
>
{saving ? 'Enregistrement...' : editing ? 'Modifier' : 'Créer'}
</button>
</div>
</div>
</div>
)}
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Nom</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Contact</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Type</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Devis</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Contrats</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Facturé</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Serveurs</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Coût serv.</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Payé</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Impayé</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{loading ? (
<tr>
<td colSpan={9} className="text-center py-8 text-slate-500">Chargement...</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-8 text-slate-500">Aucun résultat</td>
</tr>
) : (
filtered.map((client) => (
<tr key={client.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-white">{client.name}</p>
{client.siret && <p className="text-xs text-slate-400">SIRET: {client.siret}</p>}
</td>
<td className="px-4 py-3">
{client.email && <p className="text-sm text-slate-600 dark:text-slate-300">{client.email}</p>}
{client.phone && <p className="text-sm text-slate-500">{client.phone}</p>}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
client.type === 'CLIENT'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}>
{client.type === 'CLIENT' ? 'Client' : 'Prospect'}
</span>
</td>
<td className="px-4 py-3 text-center text-sm text-slate-600 dark:text-slate-300">
{client._count?.quotes || 0}
</td>
<td className="px-4 py-3 text-center text-sm text-slate-600 dark:text-slate-300">
{client._count?.contracts || 0}
</td>
<td className="px-4 py-3 text-right text-sm font-medium text-green-600">
{client.totalInvoiced ? formatCurrency(client.totalInvoiced) : '—'}
</td>
<td className="px-4 py-3 text-center text-sm text-slate-600 dark:text-slate-300">
{client.serverCount || 0}
</td>
<td className="px-4 py-3 text-right text-sm">
{(client.serverCostShare || 0) > 0 ? (
<span className="text-orange-600 font-medium">{formatCurrency(client.serverCostShare!)}</span>
) : (
<span className="text-slate-400">—</span>
)}
</td>
<td className="px-4 py-3 text-right text-sm">
<span className={`font-medium ${client.totalPaid ? 'text-green-600' : 'text-slate-400'}`}>
{client.totalPaid ? formatCurrency(client.totalPaid) : '—'}
</span>
</td>
<td className="px-4 py-3 text-right text-sm">
<span className={`font-medium ${(client.totalUnpaid || 0) > 0 ? 'text-red-500' : 'text-slate-400'}`}>
{(client.totalUnpaid || 0) > 0 ? formatCurrency(client.totalUnpaid!) : '—'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{client.type === 'PROSPECT' && (
<button
onClick={() => handleConvertToClient(client)}
className="p-1.5 rounded text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20"
title="Convertir en client"
>
<UserCheck className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleView(client)}
className="p-1.5 rounded text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Voir le détail"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => openEdit(client)}
className="p-1.5 rounded text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(client.id)}
className="p-1.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{viewingClient && (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-8 overflow-y-auto">
<div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-2xl p-6 shadow-xl">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{viewingClient.name}</h3>
<button onClick={() => { setViewingClient(null); setClientDetail(null) }} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
{loadingDetail ? (
<div className="text-center py-8 text-slate-500">Chargement...</div>
) : clientDetail ? (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4 text-sm">
<div><span className="text-slate-500">Email:</span> <span className="text-slate-900 dark:text-white">{clientDetail.email || '—'}</span></div>
<div><span className="text-slate-500">Tél:</span> <span className="text-slate-900 dark:text-white">{clientDetail.phone || '—'}</span></div>
<div className="col-span-2"><span className="text-slate-500">Adresse:</span> <span className="text-slate-900 dark:text-white">{clientDetail.address || '—'}</span></div>
<div><span className="text-slate-500">SIRET:</span> <span className="text-slate-900 dark:text-white">{clientDetail.siret || '—'}</span></div>
<div>
<span className="text-slate-500">Type:</span>{' '}
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
clientDetail.type === 'CLIENT' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
}`}>{clientDetail.type === 'CLIENT' ? 'Client' : 'Prospect'}</span>
</div>
</div>
{clientDetail.notes && (
<div>
<p className="text-sm text-slate-500 mb-1">Notes:</p>
<p className="text-sm text-slate-700 dark:text-slate-300">{clientDetail.notes}</p>
</div>
)}
{clientDetail.quotes && clientDetail.quotes.length > 0 && (
<div>
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<FileText className="h-4 w-4" /> Devis ({clientDetail.quotes.length})
</h4>
{clientDetail.quotes.map((q: any) => (
<div key={q.id} className="flex items-center justify-between p-2 rounded bg-slate-50 dark:bg-slate-700/50 mb-1 text-sm">
<span className="font-medium text-slate-900 dark:text-white">{q.name}</span>
<span className="text-green-600">{q.monthlyTotal > 0 ? `${q.monthlyTotal.toFixed(2)}€/mois` : `${q.setupTotal.toFixed(2)}€`}</span>
</div>
))}
</div>
)}
{clientDetail.contracts && clientDetail.contracts.length > 0 && (
<div>
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<Calendar className="h-4 w-4" /> Contrats ({clientDetail.contracts.length})
</h4>
{clientDetail.contracts.map((c: any) => (
<div key={c.id} className="flex items-center justify-between p-2 rounded bg-slate-50 dark:bg-slate-700/50 mb-1 text-sm">
<span className="font-medium text-slate-900 dark:text-white">{c.name}</span>
<span className="text-green-600">{c.monthlyPrice.toFixed(2)}€/mois</span>
</div>
))}
</div>
)}
{clientServers.length > 0 && (
<div>
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<Server className="h-4 w-4" /> Serveurs attribués ({clientServers.length})
</h4>
{clientServers.map((s: any) => (
<div key={s.id} className="flex items-center justify-between p-2 rounded bg-slate-50 dark:bg-slate-700/50 mb-1 text-sm">
<span className="font-medium text-slate-900 dark:text-white">{s.name}</span>
{s.ip && <span className="text-slate-400 text-xs">{s.ip}</span>}
<span className="text-orange-600">{Number(s.monthlyCost || 0).toFixed(2)}€/mois</span>
</div>
))}
</div>
)}
{(!clientDetail.quotes || clientDetail.quotes.length === 0) && (!clientDetail.contracts || clientDetail.contracts.length === 0) && clientServers.length === 0 && (
<p className="text-sm text-slate-400 text-center py-4">Aucun devis ou contrat associé</p>
)}
</div>
) : null}
</div>
</div>
)}
</div>
)
}
+413
View File
@@ -0,0 +1,413 @@
'use client'
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, X, FileText } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
interface Contract {
id: string
name: string
client: { id: string; name: string }
startDate: string
endDate: string | null
setupPrice: number
setupCost: number
monthlyPrice: number
monthlyCost: number
status: string
billingDay: number
notes: string | null
_count?: { invoices: number }
}
export function ContractsContent() {
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<Contract | null>(null)
const [clients, setClients] = useState<{ id: string; name: string }[]>([])
const [form, setForm] = useState({
clientId: '', name: '', startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: '', setupPrice: '', setupCost: '', monthlyPrice: '', monthlyCost: '',
billingDay: '1', status: 'ACTIVE', notes: '',
})
const [saving, setSaving] = useState(false)
useEffect(() => { fetchContracts(); fetchClients() }, [])
const fetchContracts = async () => {
setLoading(true)
try {
const res = await fetch('/api/contracts')
if (res.ok) {
const data = await res.json()
setContracts(data.contracts)
}
} finally {
setLoading(false)
}
}
const fetchClients = async () => {
try {
const res = await fetch('/api/clients')
if (res.ok) {
const data = await res.json()
const sorted = (data.clients || []).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name))
setClients(sorted)
}
} catch {}
}
const openNew = () => {
setEditing(null)
setForm({
clientId: '', name: '', startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: '', setupPrice: '', setupCost: '', monthlyPrice: '', monthlyCost: '',
billingDay: '1', status: 'ACTIVE', notes: '',
})
setShowForm(true)
}
const openEdit = (contract: Contract) => {
setEditing(contract)
setForm({
clientId: contract.client.id,
name: contract.name,
startDate: format(new Date(contract.startDate), 'yyyy-MM-dd'),
endDate: contract.endDate ? format(new Date(contract.endDate), 'yyyy-MM-dd') : '',
setupPrice: contract.setupPrice.toString(),
setupCost: contract.setupCost.toString(),
monthlyPrice: contract.monthlyPrice.toString(),
monthlyCost: contract.monthlyCost.toString(),
billingDay: contract.billingDay.toString(),
status: contract.status,
notes: contract.notes || '',
})
setShowForm(true)
}
const handleSave = async () => {
if (!form.clientId || !form.name.trim()) return
setSaving(true)
try {
const body = {
clientId: form.clientId,
name: form.name,
startDate: form.startDate,
endDate: form.endDate || null,
setupPrice: parseFloat(form.setupPrice) || 0,
setupCost: parseFloat(form.setupCost) || 0,
monthlyPrice: parseFloat(form.monthlyPrice) || 0,
monthlyCost: parseFloat(form.monthlyCost) || 0,
billingDay: parseInt(form.billingDay) || 1,
status: form.status,
notes: form.notes || null,
}
if (editing) {
await fetch(`/api/contracts/${editing.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} else {
await fetch('/api/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
setShowForm(false)
setEditing(null)
fetchContracts()
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer ce contrat ?')) return
try {
await fetch(`/api/contracts/${id}`, { method: 'DELETE' })
fetchContracts()
} catch {}
}
const statusColors: Record<string, string> = {
PROPOSAL: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
ACTIVE: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
PAUSED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
CANCELLED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
EXPIRED: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
}
const statusLabels: Record<string, string> = {
PROPOSAL: 'Proposition en cours', ACTIVE: 'Actif', PAUSED: 'En pause', CANCELLED: 'Annulé', EXPIRED: 'Expiré',
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Contrats</h2>
<button
onClick={openNew}
className="inline-flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Plus className="h-4 w-4" />
Nouveau contrat
</button>
</div>
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-lg p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{editing ? 'Modifier' : 'Nouveau'} contrat
</h3>
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Client *</label>
<select
value={form.clientId}
onChange={(e) => setForm({ ...form, clientId: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
>
<option value="">Sélectionner un client</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom du contrat *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date début</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date fin</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Prix mise en place</label>
<input
type="number"
step="0.01"
value={form.setupPrice}
onChange={(e) => setForm({ ...form, setupPrice: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Coût mise en place</label>
<input
type="number"
step="0.01"
value={form.setupCost}
onChange={(e) => setForm({ ...form, setupCost: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Prix mensuel</label>
<input
type="number"
step="0.01"
value={form.monthlyPrice}
onChange={(e) => setForm({ ...form, monthlyPrice: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Coût mensuel</label>
<input
type="number"
step="0.01"
value={form.monthlyCost}
onChange={(e) => setForm({ ...form, monthlyCost: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Jour de facturation</label>
<input
type="number"
min={1}
max={31}
value={form.billingDay}
onChange={(e) => setForm({ ...form, billingDay: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Statut</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
>
<option value="PROPOSAL">Proposition en cours</option>
<option value="ACTIVE">Actif</option>
<option value="PAUSED">En pause</option>
<option value="CANCELLED">Annulé</option>
<option value="EXPIRED">Expiré</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={saving || !form.clientId || !form.name.trim()}
className="px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-lg disabled:opacity-50"
>
{saving ? 'Enregistrement...' : editing ? 'Modifier' : 'Créer'}
</button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Total revenus mensuels</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(contracts.reduce((s, c) => s + (c.status === 'ACTIVE' ? c.monthlyPrice : 0), 0))}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Total coûts mensuels</p>
<p className="text-xl font-bold text-red-500">{formatCurrency(contracts.reduce((s, c) => s + (c.status === 'ACTIVE' ? c.monthlyCost : 0), 0))}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Marge mensuelle</p>
<p className="text-xl font-bold text-primary-600">
{formatCurrency(contracts.reduce((s, c) => s + (c.status === 'ACTIVE' ? c.monthlyPrice - c.monthlyCost : 0), 0))}
</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Contrats actifs</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{contracts.filter(c => c.status === 'ACTIVE').length}</p>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Contrat</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Client</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Mensuel</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Coût</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Marge</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Statut</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{loading ? (
<tr>
<td colSpan={7} className="text-center py-8 text-slate-500">Chargement...</td>
</tr>
) : contracts.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-slate-500">Aucun contrat</td>
</tr>
) : (
contracts.map((contract) => {
const margin = contract.monthlyPrice - contract.monthlyCost
const marginPct = contract.monthlyPrice > 0 ? (margin / contract.monthlyPrice) * 100 : 0
return (
<tr key={contract.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-white">{contract.name}</p>
<p className="text-xs text-slate-400">
Début: {format(new Date(contract.startDate), 'dd/MM/yyyy', { locale: fr })}
{contract.endDate && ` - Fin: ${format(new Date(contract.endDate), 'dd/MM/yyyy', { locale: fr })}`}
</p>
</td>
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">
{contract.client.name}
</td>
<td className="px-4 py-3 text-center font-medium text-green-600">
{formatCurrency(contract.monthlyPrice)}
</td>
<td className="px-4 py-3 text-center text-sm text-slate-600 dark:text-slate-300">
{formatCurrency(contract.monthlyCost)}
</td>
<td className="px-4 py-3 text-center">
<span className={`font-medium ${margin >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(margin)}
<span className="text-xs ml-1">({marginPct.toFixed(0)}%)</span>
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[contract.status] || ''}`}>
{statusLabels[contract.status] || contract.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEdit(contract)} className="p-1.5 rounded text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<Edit2 className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(contract.id)} className="p-1.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'next/navigation'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { DashboardContent } from './DashboardContent'
import { ClientsContent } from './ClientsContent'
import { SimulationContent } from './SimulationContent'
import { QuotesContent } from './QuotesContent'
import { ContractsContent } from './ContractsContent'
import { SalesContent } from './SalesContent'
import { SettingsContent } from './SettingsContent'
import { InvoicesContent } from './InvoicesContent'
import { TodoContent } from './TodoContent'
import { ServerDashboardContent } from './ServerDashboardContent'
import { ServersContent } from './ServersContent'
import { ServicesContent } from './ServicesContent'
import { TicketContent } from './TicketContent'
type View = 'dashboard' | 'clients' | 'simulation' | 'quotes' | 'contracts' | 'invoices' | 'sales' | 'todo' | 'settings' | 'serverDashboard' | 'servers' | 'services' | 'tickets'
export function DashboardClient() {
const { user, loading } = useAuth()
const router = useRouter()
const [view, setView] = useState<View>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('gestion_view')
if (saved && ['dashboard', 'clients', 'simulation', 'quotes', 'contracts', 'invoices', 'sales', 'todo', 'settings', 'serverDashboard', 'servers', 'services', 'tickets'].includes(saved)) {
return saved as View
}
}
return 'dashboard'
})
const [sidebarOpen, setSidebarOpen] = useState(false)
const [editQuoteId, setEditQuoteId] = useState<string | null>(null)
useEffect(() => {
localStorage.setItem('gestion_view', view)
}, [view])
useEffect(() => {
if (!loading && !user) {
router.push('/login')
}
}, [user, loading, router])
if (loading || !user) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Sidebar view={view} onViewChange={(v) => { setView(v as View); setEditQuoteId(null); setSidebarOpen(false) }} open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="lg:ml-64">
<Header onMenuClick={() => setSidebarOpen(true)} onViewChange={(v) => { setView(v as View); setSidebarOpen(false) }} />
<main className="p-4 lg:p-6">
{view === 'dashboard' && <DashboardContent onViewChange={(v) => setView(v as View)} />}
{view === 'clients' && <ClientsContent />}
{view === 'simulation' && <SimulationContent editQuoteId={editQuoteId} onSaved={() => { setEditQuoteId(null); setView('quotes') }} />}
{view === 'quotes' && <QuotesContent onEdit={(id) => { setEditQuoteId(id); setView('simulation') }} />}
{view === 'contracts' && <ContractsContent />}
{view === 'invoices' && <InvoicesContent />}
{view === 'sales' && <SalesContent />}
{view === 'todo' && <TodoContent />}
{view === 'settings' && <SettingsContent />}
{view === 'serverDashboard' && <ServerDashboardContent />}
{view === 'servers' && <ServersContent />}
{view === 'services' && <ServicesContent />}
{view === 'tickets' && <TicketContent />}
</main>
</div>
</div>
)
}
+358
View File
@@ -0,0 +1,358 @@
'use client'
import { useState, useEffect } from 'react'
import {
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, BarChart, Bar, Line,
} from 'recharts'
import { formatCurrency } from '@/lib/utils'
import { Users, FileText, TrendingUp, Euro, AlertCircle, FileSignature, Server } from 'lucide-react'
interface DashboardStats {
yearlyCA: number
monthlyRecurringRevenue: number
monthlyRecurringCost: number
monthlyMargin: number
monthlyMarginPercent: number
invoicedThisMonth: number
costThisMonth: number
salesCostThisMonth: number
marginThisMonth: number
activeContracts: number
activeClients: number
prospectsCount: number
unpaidRevenue: number
pendingProposalCount: number
pendingProposalMonthly: number
pausedCount: number
pausedMonthly: number
projectedMonthlyRecurring: number
projectedOneTimeSetup: number
projectedTotal: number
serverMonthlyCost: number
totalServerCostShared: number
totalServerCostSharedRounded: number
totalProposalValue: number
topQuotes: { id: string; name: string; clientName: string; totalValue: number; status: string }[]
topClients: { id: string; name: string; monthlyRecurring: number; oneTimeTotal: number; invoiceTotal: number; totalRevenue: number; contractCount: number }[]
}
interface ChartDataPoint {
month: string
monthLabel: string
recurringRevenue: number
recurringCost: number
oneTimeRevenue: number
oneTimeCost: number
invoiceRevenue: number
newClients: number
revenue: number
cost: number
margin: number
}
export function DashboardContent({ onViewChange }: { onViewChange?: (view: string) => void }) {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [chartData, setChartData] = useState<ChartDataPoint[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchDashboard()
}, [])
const fetchDashboard = async () => {
try {
const res = await fetch('/api/dashboard')
if (res.ok) {
const data = await res.json()
setStats(data.stats)
setChartData(data.chartData || [])
}
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
)
}
if (!stats) {
return (
<div className="text-center py-12 text-slate-500">
Erreur lors du chargement des données
</div>
)
}
const cards = [
{
label: 'CA annuel',
value: formatCurrency(stats.yearlyCA),
sub: `année ${new Date().getFullYear()}`,
icon: Euro,
color: 'text-primary-600 bg-primary-100 dark:bg-primary-900/30',
},
{
label: 'Revenus récurrents/mois',
value: formatCurrency(stats.monthlyRecurringRevenue),
sub: `${stats.activeContracts} contrats actifs`,
icon: Euro,
color: 'text-green-600 bg-green-100 dark:bg-green-900/30',
},
{
label: 'Coûts récurrents/mois',
value: formatCurrency(stats.monthlyRecurringCost),
sub: `Marge: ${formatCurrency(stats.monthlyMargin)} (${stats.monthlyMarginPercent.toFixed(1)}%)`,
icon: TrendingUp,
color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30',
},
{
label: 'Facturé ce mois',
value: formatCurrency(stats.invoicedThisMonth),
sub: `Marge: ${formatCurrency(stats.invoicedThisMonth - stats.salesCostThisMonth)}`,
icon: FileText,
color: 'text-purple-600 bg-purple-100 dark:bg-purple-900/30',
},
{
label: 'Non payé',
value: formatCurrency(stats.unpaidRevenue),
sub: `dont factures impayées incluses`,
icon: AlertCircle,
color: 'text-red-600 bg-red-100 dark:bg-red-900/30',
onClick: () => onViewChange?.('invoices'),
},
{
label: 'Propositions en cours',
value: stats.pendingProposalCount.toString(),
sub: `${formatCurrency(stats.pendingProposalMonthly)}/mois`,
icon: FileSignature,
color: 'text-purple-600 bg-purple-100 dark:bg-purple-900/30',
},
{
label: 'Total devis en cours',
value: formatCurrency(stats.totalProposalValue),
sub: `setup + mensualité`,
icon: FileText,
color: 'text-indigo-600 bg-indigo-100 dark:bg-indigo-900/30',
},
{
label: 'Clients actifs',
value: stats.activeClients.toString(),
sub: `${stats.prospectsCount} prospects`,
icon: Users,
color: 'text-amber-600 bg-amber-100 dark:bg-amber-900/30',
},
{
label: 'Coûts serveurs/mois',
value: formatCurrency(stats.serverMonthlyCost),
sub: `Réparti: ${formatCurrency(stats.totalServerCostSharedRounded)}`,
icon: Server,
color: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30',
},
]
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Tableau de bord</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
{cards.map((card) => {
const Tag = card.onClick ? 'button' : 'div'
return (
<Tag key={card.label} onClick={card.onClick}
className={`bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 text-left ${card.onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}`}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">{card.label}</p>
<p className="text-2xl font-bold mt-1 text-slate-900 dark:text-white">{card.value}</p>
<p className="text-xs text-slate-400 mt-1">{card.sub}</p>
</div>
<div className={`p-2.5 rounded-lg ${card.color}`}>
<card.icon className="h-5 w-5" />
</div>
</div>
</Tag>
)
})}
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-500 uppercase mb-3">Projection récurrente</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<p className="text-xs text-slate-500">Récurrent actuel</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(stats.monthlyRecurringRevenue)}</p>
</div>
<div>
<p className="text-xs text-slate-500">En proposition</p>
<p className="text-xl font-bold text-amber-600">{formatCurrency(stats.pendingProposalMonthly)}</p>
<p className="text-xs text-slate-400">{stats.pendingProposalCount} contrat(s)</p>
</div>
<div>
<p className="text-xs text-slate-500">En pause</p>
<p className="text-xl font-bold text-blue-600">{formatCurrency(stats.pausedMonthly)}</p>
<p className="text-xs text-slate-400">{stats.pausedCount} contrat(s)</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Projection totale</p>
<p className="text-xl font-bold text-primary-600">{formatCurrency(stats.projectedMonthlyRecurring)}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Revenus mensuels</h3>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.25} />
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0} />
</linearGradient>
<linearGradient id="marginGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.35} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: '8px', color: '#f1f5f9' }}
formatter={(value: number) => formatCurrency(value)}
/>
<Area type="monotone" dataKey="revenue" stroke="#0ea5e9" fill="url(#revenueGrad)" name="Revenu" strokeWidth={1.5} />
<Area type="monotone" dataKey="cost" stroke="#ef4444" fill="none" name="Coût" strokeWidth={1.5} strokeDasharray="4 3" />
<Area type="monotone" dataKey="margin" stroke="#10b981" fill="url(#marginGrad)" name="Net (poche)" strokeWidth={2.5} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Nouveaux clients par mois</h3>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: '8px', color: '#f1f5f9' }}
formatter={(value: number) => [value, 'Nouveaux clients']}
/>
<Bar dataKey="newClients" fill="#f59e0b" name="Nouveaux clients" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Top Clients</h3>
<div className="space-y-3">
{stats.topClients.length === 0 ? (
<p className="text-slate-500 text-sm">Aucun client pour le moment</p>
) : (
stats.topClients.map((client, i) => (
<div key={client.id} className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-slate-400">{i + 1}</span>
<p className="font-medium text-slate-900 dark:text-white">{client.name}</p>
</div>
<span className="font-bold text-green-600 dark:text-green-400">{formatCurrency(client.totalRevenue)}</span>
</div>
<div className="flex gap-4 text-xs text-slate-500 ml-8">
{client.monthlyRecurring > 0 && <span>Récurrent: {formatCurrency(client.monthlyRecurring)}/mois</span>}
{client.oneTimeTotal > 0 && <span>Ventes: {formatCurrency(client.oneTimeTotal)}</span>}
{client.invoiceTotal > 0 && <span>Facturé: {formatCurrency(client.invoiceTotal)}</span>}
<span>{client.contractCount} contrat(s)</span>
</div>
</div>
))
)}
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Top Devis</h3>
<div className="space-y-3">
{stats.topQuotes.length === 0 ? (
<p className="text-slate-500 text-sm">Aucun devis pour le moment</p>
) : (
stats.topQuotes.map((q, i) => (
<div key={q.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-slate-400 w-6">{i + 1}</span>
<div>
<p className="font-medium text-slate-900 dark:text-white">{q.name}</p>
<p className="text-xs text-slate-500">{q.clientName}</p>
</div>
</div>
<span className="font-semibold text-primary-600 dark:text-primary-400">
{formatCurrency(q.totalValue)}
</span>
</div>
))
)}
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Détail mensuel</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Mois</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Factures</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Ventes</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Total</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Coût</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Nvx clients</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Marge</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{chartData.length === 0 ? (
<tr><td colSpan={7} className="text-center py-8 text-slate-500">Aucune donnée</td></tr>
) : (
[...chartData].reverse().map((row) => {
const totalFacturesVentes = row.invoiceRevenue + row.oneTimeRevenue
const marginPct = totalFacturesVentes > 0 ? (row.margin / totalFacturesVentes) * 100 : 0
return (
<tr key={row.month} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3 text-sm font-medium text-slate-900 dark:text-white">{row.monthLabel}</td>
<td className="px-4 py-3 text-right text-sm text-purple-600">{formatCurrency(row.invoiceRevenue)}</td>
<td className="px-4 py-3 text-right text-sm text-blue-600">{formatCurrency(row.oneTimeRevenue)}</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-slate-900 dark:text-white">{formatCurrency(totalFacturesVentes)}</td>
<td className="px-4 py-3 text-right text-sm text-red-500">{formatCurrency(row.cost)}</td>
<td className="px-4 py-3 text-right text-sm font-medium text-amber-600">{row.newClients}</td>
<td className="px-4 py-3 text-right">
<span className={`text-sm font-semibold ${row.margin >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(row.margin)}
<span className="text-xs ml-1">({marginPct.toFixed(0)}%)</span>
</span>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+133
View File
@@ -0,0 +1,133 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Menu, LogOut, User, Bell, X, Ticket } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { formatCurrency } from '@/lib/utils'
interface HeaderProps {
onMenuClick: () => void
onViewChange?: (view: string) => void
}
interface Notification {
id: string
type: 'unpaid' | 'pending_quote' | 'invoices'
label: string
amount: number
view: string
}
export function Header({ onMenuClick, onViewChange }: HeaderProps) {
const { user, logout } = useAuth()
const [notifications, setNotifications] = useState<Notification[]>([])
const [showNotifs, setShowNotifs] = useState(false)
const [newTicketCount, setNewTicketCount] = useState(0)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setShowNotifs(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
const loadNotifs = async () => {
const notifs: Notification[] = []
const dashRes = await fetch('/api/dashboard').then(r => r.ok && r.json()).catch(() => null)
if (dashRes?.stats) {
if (dashRes.stats.unpaidRevenue > 0) {
notifs.push({ id: 'unpaid', type: 'unpaid', label: 'Ventes impay??es', amount: dashRes.stats.unpaidRevenue, view: 'sales' })
}
if (dashRes.stats.pendingProposalCount > 0) {
notifs.push({ id: 'quotes', type: 'pending_quote', label: 'Propositions en cours', amount: dashRes.stats.pendingProposalCount, view: 'contracts' })
}
}
const invRes = await fetch('/api/invoices/notifications').then(r => r.ok && r.json()).catch(() => null)
if (invRes?.count > 0) {
notifs.push({ id: 'invoices', type: 'invoices', label: 'Factures en attente', amount: invRes.count, view: 'invoices' })
}
setNotifications(notifs)
const ticketRes = await fetch('/api/tickets?status=new').then(r => r.ok && r.json()).catch(() => [])
if (Array.isArray(ticketRes)) setNewTicketCount(ticketRes.length)
}
loadNotifs()
}, [])
return (
<header className="sticky top-0 z-30 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700">
<div className="flex h-16 items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-4">
<button
className="lg:hidden p-2 rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
onClick={onMenuClick}
aria-label="Ouvrir le menu"
>
<Menu className="h-6 w-6" />
</button>
</div>
<div className="flex items-center gap-2">
<button onClick={() => onViewChange?.('tickets')}
className="p-2 rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 relative">
<Ticket className="h-5 w-5" />
{newTicketCount > 0 && (
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center h-4 min-w-[1rem] px-1 rounded-full text-[10px] font-bold bg-red-500 text-white">
{newTicketCount}
</span>
)}
</button>
<div className="relative" ref={ref}>
<button onClick={() => setShowNotifs(!showNotifs)}
className="p-2 rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 relative">
<Bell className="h-5 w-5" />
{notifications.length > 0 && (
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center h-4 min-w-[1rem] px-1 rounded-full text-[10px] font-bold bg-red-500 text-white">
{notifications.length}
</span>
)}
</button>
{showNotifs && (
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-white">Notifications</p>
<button onClick={() => setShowNotifs(false)} className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-4 w-4 text-slate-500" />
</button>
</div>
{notifications.length === 0 ? (
<p className="text-sm text-slate-500 p-4 text-center">Aucune notification</p>
) : (
<div className="py-1">
{notifications.map((n) => (
<button key={n.id} onClick={() => { setShowNotifs(false); onViewChange?.(n.view) }}
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 text-left">
<span className="text-slate-700 dark:text-slate-200">{n.label}</span>
<span className="font-semibold text-red-500">
{n.type === 'unpaid' ? formatCurrency(n.amount) : `${n.amount}`}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700">
<User className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{user?.name || 'Pierre'}</span>
</div>
<button onClick={logout} className="p-2 rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
<LogOut className="h-5 w-5" />
</button>
</div>
</div>
</header>
)
}
+208
View File
@@ -0,0 +1,208 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { formatCurrency } from '@/lib/utils'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { CheckCircle2, Circle, RefreshCw, Search, Trash2 } from 'lucide-react'
interface Invoice {
id: string
month: string
amount: number
invoiceNumber: string | null
emitted: boolean
emittedAt: string | null
paidAt: string | null
contract: {
id: string
name: string
client: { id: string; name: string }
}
}
export function InvoicesContent() {
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [search, setSearch] = useState('')
const [savingId, setSavingId] = useState<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const fetchInvoices = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/invoices')
if (res.ok) {
const data = await res.json()
setInvoices(data.invoices || [])
}
} finally {
setLoading(false)
}
}, [])
const deleteInvoice = async (id: string) => {
if (!confirm('Supprimer cette facture ?')) return
setDeleting(id)
try {
await fetch(`/api/invoices?id=${id}`, { method: 'DELETE' })
fetchInvoices()
} finally {
setDeleting(null)
}
}
useEffect(() => { fetchInvoices() }, [fetchInvoices])
const generatePast = async () => {
setGenerating(true)
try {
const res = await fetch('/api/invoices/generate', { method: 'POST' })
if (res.ok) {
const data = await res.json()
alert(`${data.created} factures créées, ${data.skipped} déjà existantes`)
fetchInvoices()
}
} finally {
setGenerating(false)
}
}
const updateInvoice = async (id: string, field: string, value: any) => {
setSavingId(id)
setInvoices(prev => prev.map(inv =>
inv.id === id ? { ...inv, [field]: value } : inv
))
try {
const body: any = { id }
if (field === 'invoiceNumber') body.invoiceNumber = value
if (field === 'emitted') body.emitted = value
if (field === 'paidAt') body.paidAt = value
const res = await fetch('/api/invoices', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
alert('Erreur sauvegarde: ' + err)
}
} finally {
setSavingId(null)
}
}
const filtered = invoices.filter(inv =>
search === '' ||
inv.contract.client.name.toLowerCase().includes(search.toLowerCase()) ||
inv.contract.name.toLowerCase().includes(search.toLowerCase()) ||
(inv.invoiceNumber && inv.invoiceNumber.toLowerCase().includes(search.toLowerCase()))
)
const groupedByMonth = filtered.reduce((acc: Record<string, Invoice[]>, inv) => {
const key = inv.month.substring(0, 7)
if (!acc[key]) acc[key] = []
acc[key].push(inv)
return acc
}, {})
const months = Object.keys(groupedByMonth).sort().reverse()
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Factures</h2>
<button type="button" onClick={(e) => { e.stopPropagation(); generatePast(); }} disabled={generating}
className="inline-flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${generating ? 'animate-spin' : ''}`} />
{generating ? 'Génération...' : 'Générer les factures passées'}
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input type="text" placeholder="Rechercher par client, contrat ou numéro de facture..." value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Chargement...</div>
) : invoices.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<p className="text-lg mb-2">Aucune facture</p>
<p className="text-sm">Cliquez sur "Générer les factures passées" pour créer les factures à partir de vos contrats</p>
</div>
) : months.length === 0 ? (
<div className="text-center py-12 text-slate-500">Aucun résultat</div>
) : (
months.map(monthKey => {
const [year, month] = monthKey.split('-')
const totalMonth = groupedByMonth[monthKey].reduce((s, inv) => s + inv.amount, 0)
const dateObj = new Date(parseInt(year), parseInt(month) - 1)
return (
<div key={monthKey} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white">
{format(dateObj, 'MMMM yyyy', { locale: fr })}
</h3>
<span className="text-sm font-medium text-primary-600">{formatCurrency(totalMonth)}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500">
<th className="text-left px-4 py-2 font-medium">Client</th>
<th className="text-left px-4 py-2 font-medium">Contrat</th>
<th className="text-right px-4 py-2 font-medium">Montant</th>
<th className="text-left px-4 py-2 font-medium">N° Facture</th>
<th className="text-center px-4 py-2 font-medium">Émise</th>
<th className="text-center px-4 py-2 font-medium">Payée le</th>
<th className="w-10 px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{groupedByMonth[monthKey].map(inv => (
<tr key={inv.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-2.5 text-sm text-slate-900 dark:text-white">{inv.contract.client.name}</td>
<td className="px-4 py-2.5 text-sm text-slate-600 dark:text-slate-300">{inv.contract.name}</td>
<td className="px-4 py-2.5 text-right text-sm font-medium">{formatCurrency(inv.amount)}</td>
<td className="px-4 py-2.5">
<input type="text" value={inv.invoiceNumber || ''}
onChange={(e) => updateInvoice(inv.id, 'invoiceNumber', e.target.value)}
placeholder="N° Henrry"
className="w-28 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
/>
</td>
<td className="px-4 py-2.5 text-center">
<button type="button" onClick={(e) => { e.stopPropagation(); updateInvoice(inv.id, 'emitted', !inv.emitted); }}
className={`inline-flex items-center gap-1 text-sm ${inv.emitted ? 'text-green-600' : 'text-slate-400 hover:text-slate-600'}`}>
{inv.emitted ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{inv.emitted ? 'Oui' : 'Non'}
</button>
</td>
<td className="px-4 py-2.5 text-center">
<input type="date" value={inv.paidAt ? inv.paidAt.substring(0, 10) : ''}
onChange={(e) => updateInvoice(inv.id, 'paidAt', e.target.value || null)}
className="w-32 px-2 py-1 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
/>
</td>
<td className="px-4 py-2.5 text-center">
<button type="button" onClick={(e) => { e.stopPropagation(); deleteInvoice(inv.id); }} disabled={deleting === inv.id}
className="p-1 rounded text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
})
)}
</div>
)
}
+303
View File
@@ -0,0 +1,303 @@
'use client'
import { useState, useEffect } from 'react'
import { Search, Trash2, Eye, X, Edit2, PlusCircle } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
interface QuoteItem {
id: string
type: string
label: string
description: string | null
quantity: number
unitPrice: number
monthlyPrice: number
isRecurring: boolean
}
interface Quote {
id: string
name: string
status: string
setupTotal: number
monthlyTotal: number
costTotal: number
marginTotal: number
discount: number
travelKm: number
travelCost: number
quoteNumber: string | null
notes: string | null
createdAt: string
client: { id: string; name: string }
items: QuoteItem[]
}
const statusLabels: Record<string, string> = {
DRAFT: 'Brouillon',
PROPOSAL: 'Proposition en cours',
SENT: 'Envoyé',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
EXPIRED: 'Expiré',
}
const statusColors: Record<string, string> = {
DRAFT: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
PROPOSAL: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
SENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
ACCEPTED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
REJECTED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
EXPIRED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
}
export function QuotesContent({ onEdit }: { onEdit?: (id: string) => void }) {
const [quotes, setQuotes] = useState<Quote[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [viewing, setViewing] = useState<Quote | null>(null)
useEffect(() => { fetchQuotes() }, [])
const fetchQuotes = async () => {
setLoading(true)
try {
const res = await fetch('/api/quotes')
if (res.ok) {
const data = await res.json()
setQuotes(data.quotes)
}
} finally {
setLoading(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer ce devis ?')) return
try {
await fetch(`/api/quotes/${id}`, { method: 'DELETE' })
fetchQuotes()
} catch {}
}
const handleStatusChange = async (quote: Quote, status: string) => {
try {
await fetch(`/api/quotes/${quote.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...quote, status, items: quote.items }),
})
fetchQuotes()
} catch {}
}
const convertToSale = async (quote: Quote) => {
if (!confirm(`Convertir ce devis en vente ? Le devis sera conservé.`)) return
try {
const items = quote.items.map(i => ({
label: i.label,
description: i.description,
quantity: i.quantity,
unitPrice: i.unitPrice,
costPrice: 0,
}))
const res = await fetch('/api/sales', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: quote.client.id,
date: new Date().toISOString().split('T')[0],
totalAmount: quote.setupTotal,
totalCost: quote.costTotal,
notes: quote.notes,
items,
}),
})
if (res.ok) {
fetchQuotes()
}
} catch {}
}
const filtered = quotes.filter(q =>
search === '' || q.name.toLowerCase().includes(search.toLowerCase()) || q.client.name.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Devis</h2>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input type="text" placeholder="Rechercher..." value={search} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Total</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{quotes.length}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Total ventes</p>
<p className="text-xl font-bold text-primary-600">{formatCurrency(quotes.reduce((s, q) => s + q.setupTotal + q.travelCost, 0))}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Récurrent mensuel total</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(quotes.reduce((s, q) => s + q.monthlyTotal, 0))}</p>
</div>
</div>
{viewing && (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-8 overflow-y-auto">
<div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-2xl p-6 shadow-xl">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{viewing.name}</h3>
<p className="text-sm text-slate-500">
Client: {viewing.client.name}
{viewing.quoteNumber && <span className="ml-3">N° {viewing.quoteNumber}</span>}
</p>
</div>
<button onClick={() => setViewing(null)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<p className="text-xs text-slate-500">Total</p>
<p className="text-lg font-bold text-primary-600">{formatCurrency(viewing.setupTotal)}</p>
</div>
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<p className="text-xs text-slate-500">Coût achat</p>
<p className="text-lg font-bold text-red-500">{formatCurrency(viewing.costTotal)}</p>
</div>
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<p className="text-xs text-slate-500">Marge</p>
<p className={`text-lg font-bold ${viewing.marginTotal >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(viewing.marginTotal)}
</p>
</div>
<div />
</div>
{viewing.discount > 0 && (
<div className="text-sm text-slate-500">Réduction: {viewing.discount}%</div>
)}
{viewing.travelKm > 0 && (
<div className="text-sm text-slate-500">Déplacement: {viewing.travelKm} km ({formatCurrency(viewing.travelCost)})</div>
)}
{viewing.items.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Prestations</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="text-left py-1 text-slate-500">Libellé</th>
<th className="text-center py-1 text-slate-500">Qté</th>
<th className="text-right py-1 text-slate-500">Prix</th>
</tr>
</thead>
<tbody>
{viewing.items.map(item => (
<tr key={item.id} className="border-b border-slate-100 dark:border-slate-700">
<td className="py-1.5">{item.label}</td>
<td className="text-center py-1.5">{item.quantity}</td>
<td className="text-right py-1.5">{formatCurrency(item.unitPrice * item.quantity)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{viewing.notes && (
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</h4>
<p className="text-sm text-slate-600 dark:text-slate-400">{viewing.notes}</p>
</div>
)}
<div className="flex gap-2">
<select value={viewing.status} onChange={(e) => { handleStatusChange(viewing, e.target.value); setViewing(null) }}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm">
<option value="DRAFT">Brouillon</option>
<option value="PROPOSAL">Proposition en cours</option>
<option value="SENT">Envoyé</option>
<option value="ACCEPTED">Accepté</option>
<option value="REJECTED">Refusé</option>
<option value="EXPIRED">Expiré</option>
</select>
</div>
</div>
</div>
</div>
)}
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Devis</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">N°</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Client</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Total</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Marge</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Statut</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{loading ? (
<tr><td colSpan={7} className="text-center py-8 text-slate-500">Chargement...</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="text-center py-8 text-slate-500">Aucun devis</td></tr>
) : (
filtered.map(quote => (
<tr key={quote.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-white">{quote.name}</p>
<p className="text-xs text-slate-400">{format(new Date(quote.createdAt), 'dd/MM/yyyy', { locale: fr })}</p>
</td>
<td className="px-4 py-3 text-sm text-slate-500">{quote.quoteNumber || '—'}</td>
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">{quote.client.name}</td>
<td className="px-4 py-3 text-right text-sm font-medium">{formatCurrency(quote.setupTotal)}</td>
<td className="px-4 py-3 text-right text-sm">
<span className={`font-medium ${quote.marginTotal >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(quote.marginTotal)}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[quote.status] || ''}`}>
{statusLabels[quote.status] || quote.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setViewing(quote)} className="p-1.5 rounded text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<Eye className="h-4 w-4" />
</button>
{onEdit && quote.status === 'DRAFT' && (
<button onClick={() => onEdit(quote.id)} className="p-1.5 rounded text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20">
<Edit2 className="h-4 w-4" />
</button>
)}
<button onClick={() => convertToSale(quote)} className="p-1.5 rounded text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20" title="Convertir en vente">
<PlusCircle className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(quote.id)} className="p-1.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+534
View File
@@ -0,0 +1,534 @@
'use client'
import { useState, useEffect } from 'react'
import { Plus, Trash2, X, Search, Edit2, Copy, CheckCircle2, Circle, FileSignature } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
interface Client {
id: string
name: string
email: string | null
phone: string | null
address: string | null
siret: string | null
isProfessional: boolean | null
}
interface SaleItem {
id: string
label: string
description: string | null
quantity: number
unitPrice: number
costPrice: number
}
interface Sale {
id: string
client: { id: string; name: string }
date: string
totalAmount: number
totalCost: number
paid: boolean
notes: string | null
items: SaleItem[]
}
export function SalesContent() {
const [sales, setSales] = useState<Sale[]>([])
const [clients, setClients] = useState<Client[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [search, setSearch] = useState('')
const [selectedClient, setSelectedClient] = useState('')
const [clientName, setClientName] = useState('')
const [clientEmail, setClientEmail] = useState('')
const [clientPhone, setClientPhone] = useState('')
const [clientAddress, setClientAddress] = useState('')
const [clientSiret, setClientSiret] = useState('')
const [isProfessional, setIsProfessional] = useState(true)
const [saleDate, setSaleDate] = useState(format(new Date(), 'yyyy-MM-dd'))
const [notes, setNotes] = useState('')
const [items, setItems] = useState<{ id: string; label: string; description: string; quantity: number; unitPrice: number; costPrice: number }[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const isNewClient = !selectedClient
useEffect(() => { fetchSales(); fetchClients() }, [])
const fetchSales = async () => {
setLoading(true)
try {
const res = await fetch('/api/sales')
if (res.ok) {
const data = await res.json()
setSales(data.sales)
}
} finally {
setLoading(false)
}
}
const fetchClients = async () => {
try {
const res = await fetch('/api/clients')
if (res.ok) {
const data = await res.json()
setClients(data.clients)
}
} catch {}
}
const openNew = () => {
setEditingId(null)
setSelectedClient('')
setClientName('')
setClientEmail('')
setClientPhone('')
setClientAddress('')
setClientSiret('')
setIsProfessional(true)
setSaleDate(format(new Date(), 'yyyy-MM-dd'))
setNotes('')
setItems([{ id: crypto.randomUUID(), label: '', description: '', quantity: 1, unitPrice: 0, costPrice: 0 }])
setSaved(false)
setShowForm(true)
}
const openEdit = async (sale: Sale) => {
setEditingId(sale.id)
setSelectedClient(sale.client.id)
setClientName('')
setClientEmail('')
setClientPhone('')
setClientAddress('')
setClientSiret('')
setIsProfessional(true)
setSaleDate(format(new Date(sale.date), 'yyyy-MM-dd'))
setNotes(sale.notes || '')
setItems(sale.items.map(i => ({ id: crypto.randomUUID(), label: i.label, description: i.description || '', quantity: i.quantity, unitPrice: i.unitPrice, costPrice: i.costPrice })))
setSaved(false)
setShowForm(true)
}
const addItem = () => {
setItems([...items, { id: crypto.randomUUID(), label: '', description: '', quantity: 1, unitPrice: 0, costPrice: 0 }])
}
const updateItem = (id: string, field: string, value: any) => {
setItems(items.map(item => item.id === id ? { ...item, [field]: value } : item))
}
const removeItem = (id: string) => {
setItems(items.filter(item => item.id !== id))
}
const totals = items.reduce((acc, item) => {
acc.amount += (item.unitPrice || 0) * (item.quantity || 1)
acc.cost += (item.costPrice || 0) * (item.quantity || 1)
return acc
}, { amount: 0, cost: 0 })
const handleSave = async () => {
if (!selectedClient && !clientName.trim()) return
if (items.length === 0 || !items.some(i => i.label.trim())) return
setSaving(true)
setSaved(false)
try {
const body: any = {
clientId: selectedClient || null,
clientName: isNewClient ? clientName.trim() : undefined,
clientEmail: isNewClient ? clientEmail || null : undefined,
clientPhone: isNewClient ? clientPhone || null : undefined,
clientAddress: isNewClient ? clientAddress || null : undefined,
clientSiret: isNewClient ? clientSiret || null : undefined,
isProfessional: isNewClient ? isProfessional : undefined,
date: saleDate,
totalAmount: totals.amount,
totalCost: totals.cost,
notes: notes || null,
items: items.filter(i => i.label.trim()).map(i => ({
label: i.label,
description: i.description || null,
quantity: i.quantity || 1,
unitPrice: i.unitPrice || 0,
costPrice: i.costPrice || 0,
})),
}
const method = editingId ? 'PUT' : 'POST'
const url = editingId ? `/api/sales/${editingId}` : '/api/sales'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
setSaved(true)
setShowForm(false)
setEditingId(null)
fetchSales()
fetchClients()
}
} finally {
setSaving(false)
}
}
const handleDuplicate = async (sale: Sale) => {
if (!confirm('Dupliquer cette vente ?')) return
try {
const body = {
clientId: sale.client.id,
date: format(new Date(), 'yyyy-MM-dd'),
totalAmount: sale.totalAmount,
totalCost: sale.totalCost,
notes: sale.notes,
items: sale.items.map(i => ({
label: i.label,
description: i.description,
quantity: i.quantity,
unitPrice: i.unitPrice,
costPrice: i.costPrice,
})),
}
await fetch('/api/sales', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
fetchSales()
} catch {}
}
const togglePaid = async (sale: Sale) => {
try {
await fetch(`/api/sales/${sale.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid: !sale.paid }),
})
fetchSales()
} catch {}
}
const convertToQuote = async (sale: Sale) => {
if (!confirm(`Convertir cette vente en devis ? La vente sera supprimée.`)) return
try {
const items = sale.items.map(i => ({
type: 'CUSTOM',
label: i.label,
description: i.description,
quantity: i.quantity,
unitPrice: i.unitPrice,
costPrice: i.costPrice,
isRecurring: false,
setupPrice: i.unitPrice,
setupCost: i.costPrice,
monthlyPrice: 0,
monthlyCost: 0,
}))
const res = await fetch('/api/quotes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: sale.client.id,
name: `Devis - ${sale.client.name}`,
setupTotal: sale.totalAmount,
costTotal: sale.totalCost,
marginTotal: sale.totalAmount - sale.totalCost,
notes: sale.notes,
items,
}),
})
if (res.ok) {
await fetch(`/api/sales/${sale.id}`, { method: 'DELETE' })
fetchSales()
}
} catch {}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer cette vente ?')) return
try {
await fetch(`/api/sales/${id}`, { method: 'DELETE' })
fetchSales()
} catch {}
}
const filtered = sales.filter(s =>
search === '' || s.client.name.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Ventes hors contrat</h2>
<button onClick={openNew}
className="inline-flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
<Plus className="h-4 w-4" />
Nouvelle vente
</button>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input type="text" placeholder="Rechercher..." value={search} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
</div>
{showForm && (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-8 overflow-y-auto">
<div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-2xl p-6 shadow-xl">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{editingId ? 'Modifier la vente' : 'Nouvelle vente'}</h3>
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Client</label>
<select value={selectedClient} onChange={(e) => setSelectedClient(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm mb-2">
<option value="">Nouveau client...</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{isNewClient && (
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-700/50 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Nom *</label>
<input type="text" value={clientName} onChange={(e) => setClientName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Email</label>
<input type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Téléphone</label>
<input type="text" value={clientPhone} onChange={(e) => setClientPhone(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Adresse</label>
<input type="text" value={clientAddress} onChange={(e) => setClientAddress(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input type="checkbox" checked={isProfessional} onChange={(e) => setIsProfessional(e.target.checked)}
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500" />
<span className="text-sm text-slate-700 dark:text-slate-300">Client professionnel</span>
</label>
{isProfessional && (
<div className="flex-1">
<label className="block text-xs font-medium text-slate-500 mb-1">SIRET</label>
<input type="text" value={clientSiret} onChange={(e) => setClientSiret(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
)}
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date</label>
<input type="date" value={saleDate} onChange={(e) => setSaleDate(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Prestations</label>
<button onClick={addItem}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors">
<Plus className="h-3 w-3" /> Ajouter une ligne
</button>
</div>
<div className="space-y-2">
{items.map((item, idx) => (
<div key={item.id} className="flex flex-wrap items-end gap-2 p-2 rounded bg-slate-50 dark:bg-slate-700/50">
<div className="flex-1 min-w-[120px]">
<label className="block text-xs text-slate-500 mb-0.5">Libellé</label>
<input type="text" value={item.label} onChange={(e) => updateItem(item.id, 'label', e.target.value)}
placeholder={`Prestation ${idx + 1}`}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div className="w-16">
<label className="block text-xs text-slate-500 mb-0.5">Qté</label>
<input type="number" min={1} value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value) || 1)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm text-center" />
</div>
<div className="w-24">
<label className="block text-xs text-slate-500 mb-0.5">Prix vente</label>
<input type="number" step="0.01" value={item.unitPrice} onChange={(e) => updateItem(item.id, 'unitPrice', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div className="w-24">
<label className="block text-xs text-slate-500 mb-0.5">Coût achat</label>
<input type="number" step="0.01" value={item.costPrice} onChange={(e) => updateItem(item.id, 'costPrice', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div className="w-20 text-right self-end pb-1">
<span className="text-xs text-slate-500">{formatCurrency((item.unitPrice || 0) * (item.quantity || 1))}</span>
</div>
<button onClick={() => removeItem(item.id)} className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded self-end mb-0.5">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-6 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
<div className="text-right">
<p className="text-xs text-slate-500">Total vente</p>
<p className="text-lg font-bold text-green-600">{formatCurrency(totals.amount)}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500">Coût total</p>
<p className="text-lg font-bold text-red-500">{formatCurrency(totals.cost)}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500">Marge</p>
<p className={`text-lg font-bold ${totals.amount - totals.cost >= 0 ? 'text-primary-600' : 'text-red-500'}`}>
{formatCurrency(totals.amount - totals.cost)}
<span className="text-sm ml-1">
({totals.amount > 0 ? ((totals.amount - totals.cost) / totals.amount * 100).toFixed(0) : 0}%)
</span>
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg">Annuler</button>
<button onClick={handleSave} disabled={saving || (!selectedClient && !clientName.trim()) || !items.some(i => i.label.trim())}
className="px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-lg disabled:opacity-50">
{saving ? 'Enregistrement...' : editingId ? 'Enregistrer les modifications' : 'Enregistrer la vente'}
</button>
</div>
</div>
</div>
)}
{loading ? (
<div className="text-center py-12 text-slate-500">Chargement...</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-slate-500">Aucune vente</div>
) : (
(() => {
const groupedByMonth = filtered.reduce((acc: Record<string, Sale[]>, sale) => {
const key = sale.date.substring(0, 7)
if (!acc[key]) acc[key] = []
acc[key].push(sale)
return acc
}, {} as Record<string, Sale[]>)
const months = Object.keys(groupedByMonth).sort().reverse()
return months.map(monthKey => {
const [year, month] = monthKey.split('-')
const totalMonth = groupedByMonth[monthKey].reduce((s, sale) => s + sale.totalAmount, 0)
const dateObj = new Date(parseInt(year), parseInt(month) - 1)
return (
<div key={monthKey} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white">
{format(dateObj, 'MMMM yyyy', { locale: fr })}
</h3>
<span className="text-sm font-medium text-primary-600">{formatCurrency(totalMonth)}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500">
<th className="text-left px-4 py-2 font-medium">Date</th>
<th className="text-left px-4 py-2 font-medium">Client</th>
<th className="text-right px-4 py-2 font-medium">Montant</th>
<th className="text-right px-4 py-2 font-medium">Coût</th>
<th className="text-right px-4 py-2 font-medium">Marge</th>
<th className="text-center px-4 py-2 font-medium">Statut</th>
<th className="text-center px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{groupedByMonth[monthKey].map(sale => {
const margin = sale.totalAmount - sale.totalCost
const marginPct = sale.totalAmount > 0 ? (margin / sale.totalAmount) * 100 : 0
return (
<tr key={sale.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-2.5 text-sm text-slate-600 dark:text-slate-300">
{format(new Date(sale.date), 'dd/MM/yyyy', { locale: fr })}
</td>
<td className="px-4 py-2.5 font-medium text-slate-900 dark:text-white">{sale.client.name}</td>
<td className="px-4 py-2.5 text-right font-medium text-green-600">{formatCurrency(sale.totalAmount)}</td>
<td className="px-4 py-2.5 text-right text-sm text-slate-600 dark:text-slate-300">{formatCurrency(sale.totalCost)}</td>
<td className="px-4 py-2.5 text-right">
<span className={`font-medium ${margin >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(margin)}
<span className="text-xs ml-1">({marginPct.toFixed(0)}%)</span>
</span>
</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => togglePaid(sale)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
sale.paid
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}>
{sale.paid ? <CheckCircle2 className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}
{sale.paid ? 'Payé' : 'Non payé'}
</button>
</td>
<td className="px-4 py-2.5 text-center">
<div className="flex items-center justify-center gap-1">
<button onClick={() => openEdit(sale)}
className="p-1.5 rounded text-slate-500 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20">
<Edit2 className="h-4 w-4" />
</button>
<button onClick={() => handleDuplicate(sale)}
className="p-1.5 rounded text-slate-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20">
<Copy className="h-4 w-4" />
</button>
<button onClick={() => convertToQuote(sale)}
className="p-1.5 rounded text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20"
title="Convertir en devis">
<FileSignature className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(sale.id)}
className="p-1.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
})
})()
)}
</div>
)
}
+226
View File
@@ -0,0 +1,226 @@
'use client'
import { useState, useEffect } from 'react'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { Server, Monitor, HardDrive, Activity, Clock, Calendar, DollarSign, Power, PowerOff } from 'lucide-react'
interface ServerMetric {
cpuUsage: number | null
ramUsage: number | null
diskUsage: number | null
uptime: number | null
timestamp: string
}
interface ServerData {
id: string
name: string
ip: string
os: string
cpu: string
ram: string
disk: string
startDate: string | null
endDate: string | null
monthlyCost: number
status: string
notes: string
latest_metric: ServerMetric | null
}
interface DashboardStats {
totalServers: number
activeServers: number
totalMonthlyCost: number
onlineServers: number
offlineServers: number
}
export function ServerDashboardContent() {
const [servers, setServers] = useState<ServerData[]>([])
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
fetchDashboard()
}, [])
const fetchDashboard = async () => {
try {
const res = await fetch('/api/servers/dashboard')
if (res.ok) {
const data = await res.json()
setServers(data.servers || [])
setStats(data.stats)
}
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
)
}
const cards = [
{
label: 'Serveurs',
value: stats?.totalServers.toString() || '0',
sub: `${stats?.activeServers || 0} actifs`,
icon: Server,
color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30',
},
{
label: 'En ligne',
value: stats?.onlineServers.toString() || '0',
sub: `${stats?.offlineServers || 0} hors ligne`,
icon: Power,
color: 'text-green-600 bg-green-100 dark:bg-green-900/30',
},
{
label: 'Coût mensuel',
value: formatCurrency(stats?.totalMonthlyCost || 0),
sub: 'total serveurs',
icon: DollarSign,
color: 'text-purple-600 bg-purple-100 dark:bg-purple-900/30',
},
]
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Dashboard Tech</h2>
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Rechercher un serveur..."
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm w-64" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{cards.map((card) => (
<div key={card.label}
className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">{card.label}</p>
<p className="text-2xl font-bold mt-1 text-slate-900 dark:text-white">{card.value}</p>
<p className="text-xs text-slate-400 mt-1">{card.sub}</p>
</div>
<div className={`p-2.5 rounded-lg ${card.color}`}>
<card.icon className="h-5 w-5" />
</div>
</div>
</div>
))}
</div>
<div className="space-y-4">
{(() => {
const filtered = servers.filter(s =>
!search || s.name.toLowerCase().includes(search.toLowerCase()) || s.ip.toLowerCase().includes(search.toLowerCase()) || s.os.toLowerCase().includes(search.toLowerCase())
)
return filtered.length === 0 ? (
<p className="text-slate-500 text-center py-8">Aucun serveur. Ajoutez-en dans la section Serveurs.</p>
) : (
filtered.map((server) => {
const cpu = server.latest_metric?.cpuUsage
const ram = server.latest_metric?.ramUsage
const disk = server.latest_metric?.diskUsage
const isOnline = cpu != null
const isExpired = server.endDate && new Date(server.endDate) < new Date()
return (
<div key={server.id} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isOnline ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'}`}>
{isOnline ? <Power className="h-5 w-5 text-green-600" /> : <PowerOff className="h-5 w-5 text-red-500" />}
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-white">{server.name}</h3>
<p className="text-sm text-slate-500">{server.ip} {server.os ? `• ${server.os}` : ''}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={cn(
'px-2.5 py-1 rounded-full text-xs font-medium',
server.status === 'ACTIVE' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
server.status === 'EXPIRED' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
server.status === 'PAUSED' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' :
'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400'
)}>
{server.status === 'ACTIVE' ? 'Actif' : server.status === 'EXPIRED' ? 'Expiré' : server.status === 'PAUSED' ? 'En pause' : server.status}
</span>
{isExpired && <span className="px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">Expiré</span>}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* CPU */}
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center gap-2 mb-1">
<Monitor className="h-4 w-4 text-slate-400" />
<span className="text-xs text-slate-500">CPU</span>
</div>
<p className="text-lg font-bold text-slate-900 dark:text-white">
{cpu != null ? `${cpu.toFixed(1)}%` : '--'}
</p>
{server.cpu && <p className="text-xs text-slate-400">{server.cpu}</p>}
</div>
{/* RAM */}
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center gap-2 mb-1">
<Activity className="h-4 w-4 text-slate-400" />
<span className="text-xs text-slate-500">RAM</span>
</div>
<p className="text-lg font-bold text-slate-900 dark:text-white">
{ram != null ? `${ram.toFixed(1)}%` : '--'}
</p>
{server.ram && <p className="text-xs text-slate-400">{server.ram}</p>}
</div>
{/* Disque */}
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center gap-2 mb-1">
<HardDrive className="h-4 w-4 text-slate-400" />
<span className="text-xs text-slate-500">Disque</span>
</div>
<p className="text-lg font-bold text-slate-900 dark:text-white">
{disk != null ? `${disk.toFixed(1)}%` : '--'}
</p>
{server.disk && <p className="text-xs text-slate-400">{server.disk}</p>}
</div>
{/* Durée / Fin */}
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-4 w-4 text-slate-400" />
<span className="text-xs text-slate-500">{server.endDate ? 'Jusqu\'au' : 'Début'}</span>
</div>
<p className="text-lg font-bold text-slate-900 dark:text-white">
{server.endDate ? formatDate(server.endDate) : server.startDate ? formatDate(server.startDate) : '--'}
</p>
{server.monthlyCost > 0 && <p className="text-xs text-slate-400">{formatCurrency(server.monthlyCost)}/mois</p>}
</div>
</div>
{server.latest_metric?.timestamp && (
<p className="text-xs text-slate-400 flex items-center gap-1">
<Clock className="h-3 w-3" />
Dernière mise à jour : {new Date(server.latest_metric.timestamp).toLocaleString('fr-FR')}
</p>
)}
</div>
</div>
)
})
)})()}
</div>
</div>
)
}
+254
View File
@@ -0,0 +1,254 @@
'use client'
import { useState, useEffect } from 'react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Plus, Pencil, Trash2, Server, Power, PowerOff, X } from 'lucide-react'
interface ServerData {
id: string
name: string
ip: string
os: string
cpu: string
ram: string
disk: string
startDate: string | null
endDate: string | null
monthlyCost: number
status: string
notes: string
latest_metric: { cpuUsage: number | null } | null
}
const emptyForm = {
name: '',
ip: '',
os: '',
cpu: '',
ram: '',
disk: '',
startDate: '',
endDate: '',
monthlyCost: '',
status: 'ACTIVE',
notes: '',
}
export function ServersContent() {
const [servers, setServers] = useState<ServerData[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [form, setForm] = useState(emptyForm)
const [search, setSearch] = useState('')
useEffect(() => {
fetchServers()
}, [])
const fetchServers = async () => {
try {
const res = await fetch('/api/servers')
if (res.ok) setServers(await res.json())
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const body: any = { ...form }
if (body.monthlyCost) body.monthlyCost = Number(body.monthlyCost)
if (!body.startDate) body.startDate = null
if (!body.endDate) body.endDate = null
const url = editId ? `/api/servers/${editId}` : '/api/servers'
const method = editId ? 'PATCH' : 'POST'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (res.ok) {
setShowForm(false)
setEditId(null)
setForm(emptyForm)
fetchServers()
}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer ce serveur ?')) return
const res = await fetch(`/api/servers/${id}`, { method: 'DELETE' })
if (res.ok) fetchServers()
}
const handleEdit = (s: ServerData) => {
setEditId(s.id)
setForm({
name: s.name,
ip: s.ip,
os: s.os,
cpu: s.cpu,
ram: s.ram,
disk: s.disk,
startDate: s.startDate ? s.startDate.slice(0, 10) : '',
endDate: s.endDate ? s.endDate.slice(0, 10) : '',
monthlyCost: s.monthlyCost.toString(),
status: s.status,
notes: s.notes,
})
setShowForm(true)
}
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Serveurs</h2>
<div className="flex items-center gap-3">
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Rechercher..."
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm w-48" />
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm(emptyForm) }}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
<Plus className="h-4 w-4" />
Ajouter
</button>
</div>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white">{editId ? 'Modifier' : 'Nouveau'} serveur</h3>
<button type="button" onClick={() => { setShowForm(false); setEditId(null) }} className="text-slate-400 hover:text-slate-600"><X className="h-5 w-5" /></button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom *</label>
<input type="text" required value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">IP</label>
<input type="text" value={form.ip} onChange={e => setForm({ ...form, ip: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">OS</label>
<input type="text" value={form.os} onChange={e => setForm({ ...form, os: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">CPU</label>
<input type="text" value={form.cpu} onChange={e => setForm({ ...form, cpu: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">RAM</label>
<input type="text" value={form.ram} onChange={e => setForm({ ...form, ram: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Disque</label>
<input type="text" value={form.disk} onChange={e => setForm({ ...form, disk: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date début</label>
<input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date fin</label>
<input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Coût mensuel (€)</label>
<input type="number" step="0.01" min="0" value={form.monthlyCost} onChange={e => setForm({ ...form, monthlyCost: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Statut</label>
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="ACTIVE">Actif</option>
<option value="PAUSED">En pause</option>
<option value="EXPIRED">Expiré</option>
</select>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => { setShowForm(false); setEditId(null) }}
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg">Annuler</button>
<button type="submit"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
{editId ? 'Enregistrer' : 'Créer'}
</button>
</div>
</form>
)}
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Statut</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Nom</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">IP</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">OS</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">CPU</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">RAM</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Disque</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Fin</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Coût</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{(() => {
const filtered = servers.filter(s =>
!search || s.name.toLowerCase().includes(search.toLowerCase()) || s.ip.toLowerCase().includes(search.toLowerCase())
)
return filtered.length === 0 ? (
<tr><td colSpan={10} className="text-center py-8 text-slate-500">{search ? 'Aucun résultat' : 'Aucun serveur'}</td></tr>
) : (
filtered.map((s) => {
const isOnline = s.latest_metric?.cpuUsage != null
return (
<tr key={s.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
{isOnline ? <Power className="h-4 w-4 text-green-500" /> : <PowerOff className="h-4 w-4 text-red-400" />}
</td>
<td className="px-4 py-3 text-sm font-medium text-slate-900 dark:text-white">{s.name}</td>
<td className="px-4 py-3 text-sm text-slate-500">{s.ip || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-500">{s.os || '--'}</td>
<td className="px-4 py-3 text-right text-sm text-slate-500">{s.cpu || '--'}</td>
<td className="px-4 py-3 text-right text-sm text-slate-500">{s.ram || '--'}</td>
<td className="px-4 py-3 text-right text-sm text-slate-500">{s.disk || '--'}</td>
<td className="px-4 py-3 text-right text-sm text-slate-500">{s.endDate ? formatDate(s.endDate) : '--'}</td>
<td className="px-4 py-3 text-right text-sm font-medium text-slate-900 dark:text-white">{s.monthlyCost > 0 ? formatCurrency(s.monthlyCost) : '--'}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => handleEdit(s)} className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"><Pencil className="h-4 w-4" /></button>
<button onClick={() => handleDelete(s.id)} className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"><Trash2 className="h-4 w-4" /></button>
</div>
</td>
</tr>
)
})
)})()}
</tbody>
</table>
</div>
</div>
</div>
)
}
+358
View File
@@ -0,0 +1,358 @@
'use client'
import { useState, useEffect } from 'react'
import { formatDate } from '@/lib/utils'
import { Plus, Pencil, Trash2, X, RefreshCw, Globe, Server, Box, Radio, Activity } from 'lucide-react'
interface ServiceData {
id: string
serverId: string
server_name: string
server_ip: string
name: string
type: string
port: number | null
technology: string
url: string
status: string
cpuUsage: number | null
ramUsage: number | null
diskUsage: number | null
pid: number | null
processName: string
notes: string
}
const typeIcons: Record<string, any> = {
service: Server,
website: Globe,
application: Box,
webapp: Globe,
container: Box,
port: Radio,
}
const typeLabels: Record<string, string> = {
service: 'Service',
website: 'Site web',
application: 'Application',
webapp: 'App web',
container: 'Conteneur',
port: 'Port',
}
const emptyForm = {
serverId: '',
name: '',
type: 'service',
port: '',
technology: '',
url: '',
status: 'running',
cpuUsage: '',
ramUsage: '',
diskUsage: '',
pid: '',
processName: '',
notes: '',
}
export function ServicesContent() {
const [services, setServices] = useState<ServiceData[]>([])
const [servers, setServers] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [form, setForm] = useState(emptyForm)
const [discovering, setDiscovering] = useState(false)
const [filterServer, setFilterServer] = useState<string>('')
const [search, setSearch] = useState('')
useEffect(() => {
fetchServices()
fetchServers()
}, [])
const fetchServices = async () => {
try {
const res = await fetch('/api/services')
if (res.ok) setServices(await res.json())
} finally {
setLoading(false)
}
}
const fetchServers = async () => {
try {
const res = await fetch('/api/servers')
if (res.ok) setServers(await res.json())
} catch {}
}
const handleDiscover = async (serverId: string) => {
setDiscovering(true)
try {
const res = await fetch(`/api/servers/${serverId}/services/discover`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
alert(`${data.total} services découverts, ${data.inserted} nouveaux`)
fetchServices()
}
} finally {
setDiscovering(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const body: any = { ...form }
Object.keys(body).forEach(k => {
if (body[k] === '' && !['name', 'notes', 'serverId'].includes(k)) body[k] = null
})
const url = editId ? `/api/services/${editId}` : '/api/services'
const method = editId ? 'PATCH' : 'POST'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (res.ok) {
setShowForm(false); setEditId(null); setForm(emptyForm); fetchServices()
}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer ce service ?')) return
const res = await fetch(`/api/services/${id}`, { method: 'DELETE' })
if (res.ok) fetchServices()
}
const handleEdit = (s: ServiceData) => {
setEditId(s.id)
setForm({
serverId: s.serverId,
name: s.name,
type: s.type,
port: s.port?.toString() || '',
technology: s.technology,
url: s.url,
status: s.status,
cpuUsage: s.cpuUsage?.toString() || '',
ramUsage: s.ramUsage?.toString() || '',
diskUsage: s.diskUsage?.toString() || '',
pid: s.pid?.toString() || '',
processName: s.processName,
notes: s.notes,
})
setShowForm(true)
}
const filtered = (filterServer ? services.filter(s => s.serverId === filterServer) : services).filter(s =>
!search || s.name.toLowerCase().includes(search.toLowerCase()) || s.server_name?.toLowerCase().includes(search.toLowerCase()) || s.technology?.toLowerCase().includes(search.toLowerCase())
)
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Services</h2>
<div className="flex items-center gap-3">
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Rechercher..."
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm w-48" />
<select value={filterServer} onChange={e => setFilterServer(e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="">Tous les serveurs</option>
{servers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
{filterServer && (
<button onClick={() => handleDiscover(filterServer)} disabled={discovering}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${discovering ? 'animate-spin' : ''}`} />
Découvrir
</button>
)}
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm(emptyForm) }}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
<Plus className="h-4 w-4" /> Ajouter
</button>
</div>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white">{editId ? 'Modifier' : 'Nouveau'} service</h3>
<button type="button" onClick={() => { setShowForm(false); setEditId(null) }} className="text-slate-400 hover:text-slate-600"><X className="h-5 w-5" /></button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Serveur *</label>
<select required value={form.serverId} onChange={e => setForm({ ...form, serverId: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="">Sélectionner...</option>
{servers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom *</label>
<input type="text" required value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="service">Service</option>
<option value="website">Site web</option>
<option value="application">Application</option>
<option value="webapp">App web</option>
<option value="container">Conteneur</option>
<option value="port">Port</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Statut</label>
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="running">En ligne</option>
<option value="stopped">Arrêté</option>
<option value="error">Erreur</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Port</label>
<input type="number" min="1" max="65535" value={form.port} onChange={e => setForm({ ...form, port: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Technologie</label>
<input type="text" value={form.technology} onChange={e => setForm({ ...form, technology: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">URL</label>
<input type="url" value={form.url} onChange={e => setForm({ ...form, url: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">PID</label>
<input type="number" min="1" value={form.pid} onChange={e => setForm({ ...form, pid: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">CPU (%)</label>
<input type="number" step="0.01" min="0" max="100" value={form.cpuUsage} onChange={e => setForm({ ...form, cpuUsage: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">RAM (Mo)</label>
<input type="number" step="0.1" min="0" value={form.ramUsage} onChange={e => setForm({ ...form, ramUsage: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Disque (Mo)</label>
<input type="number" step="0.1" min="0" value={form.diskUsage} onChange={e => setForm({ ...form, diskUsage: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Processus</label>
<input type="text" value={form.processName} onChange={e => setForm({ ...form, processName: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div className="sm:col-span-2 lg:col-span-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => { setShowForm(false); setEditId(null) }}
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg">Annuler</button>
<button type="submit"
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
{editId ? 'Enregistrer' : 'Créer'}
</button>
</div>
</form>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.length === 0 ? (
<p className="text-slate-500 col-span-full text-center py-8">{search ? 'Aucun résultat' : 'Aucun service. Ajoutez-en ou cliquez sur "Découvrir" pour scanner le serveur.'}</p>
) : (
filtered.map((svc) => {
const Icon = typeIcons[svc.type] || Activity
return (
<div key={svc.id} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
svc.status === 'running' ? 'bg-green-100 dark:bg-green-900/30 text-green-600' :
svc.status === 'stopped' ? 'bg-slate-100 dark:bg-slate-700 text-slate-500' :
'bg-red-100 dark:bg-red-900/30 text-red-500'
}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-white text-sm">{svc.name}</h3>
<p className="text-xs text-slate-500">{svc.server_name} ({svc.server_ip})</p>
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => handleEdit(svc)} className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"><Pencil className="h-3.5 w-3.5" /></button>
<button onClick={() => handleDelete(svc.id)} className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
svc.status === 'running' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
svc.status === 'stopped' ? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400' :
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}>
{svc.status === 'running' ? 'En ligne' : svc.status === 'stopped' ? 'Arrêté' : 'Erreur'}
</span>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{typeLabels[svc.type] || svc.type}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{svc.port && <div><span className="text-slate-400">Port:</span> <span className="text-slate-600 dark:text-slate-300 font-medium">{svc.port}</span></div>}
{svc.technology && <div className="truncate"><span className="text-slate-400">Tech:</span> <span className="text-slate-600 dark:text-slate-300">{svc.technology}</span></div>}
{svc.url && <div className="col-span-2 truncate"><span className="text-slate-400">URL:</span> <a href={svc.url} target="_blank" className="text-primary-600 hover:underline">{svc.url}</a></div>}
{svc.pid && <div><span className="text-slate-400">PID:</span> <span className="text-slate-600 dark:text-slate-300">{svc.pid}</span></div>}
{svc.processName && <div><span className="text-slate-400">Proc:</span> <span className="text-slate-600 dark:text-slate-300">{svc.processName}</span></div>}
</div>
{(svc.cpuUsage != null || svc.ramUsage != null || svc.diskUsage != null) && (
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 grid grid-cols-3 gap-3">
{svc.cpuUsage != null && (
<div>
<p className="text-[10px] text-slate-400">CPU</p>
<p className="text-sm font-bold text-slate-900 dark:text-white">{svc.cpuUsage.toFixed(1)}%</p>
</div>
)}
{svc.ramUsage != null && (
<div>
<p className="text-[10px] text-slate-400">RAM</p>
<p className="text-sm font-bold text-slate-900 dark:text-white">{svc.ramUsage.toFixed(1)} Mo</p>
</div>
)}
{svc.diskUsage != null && (
<div>
<p className="text-[10px] text-slate-400">Disque</p>
<p className="text-sm font-bold text-slate-900 dark:text-white">{svc.diskUsage.toFixed(1)} Mo</p>
</div>
)}
</div>
)}
</div>
)
})
)}
</div>
</div>
)
}
+238
View File
@@ -0,0 +1,238 @@
'use client'
import { useState, useEffect } from 'react'
import { Save, RefreshCw, Database, User, Lock } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
interface Product {
id: string
key: string
label: string
costPrice: number
sellPrice: number
unit: string
category: string
isActive: boolean
}
export function SettingsContent() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [savingId, setSavingId] = useState<string | null>(null)
const [seeding, setSeeding] = useState(false)
const [seedDone, setSeedDone] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordMsg, setPasswordMsg] = useState('')
const [passwordError, setPasswordError] = useState('')
const [passwordSaving, setPasswordSaving] = useState(false)
useEffect(() => { fetchProducts() }, [])
const fetchProducts = async () => {
setLoading(true)
try {
const res = await fetch('/api/settings/products')
if (res.ok) {
const data = await res.json()
setProducts(data.products)
}
} finally {
setLoading(false)
}
}
const handleUpdate = async (product: Product, field: 'costPrice' | 'sellPrice' | 'isActive', value: number | boolean) => {
const updated = { ...product, [field]: value }
setSavingId(product.id)
try {
await fetch('/api/settings/products', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated),
})
setProducts(prev => prev.map(p => p.id === product.id ? updated : p))
} finally {
setSavingId(null)
}
}
const handleSeed = async () => {
setSeeding(true)
try {
await fetch('/api/settings/products', { method: 'POST' })
setSeedDone(true)
fetchProducts()
} finally {
setSeeding(false)
}
}
const handleChangePassword = async () => {
setPasswordError('')
setPasswordMsg('')
if (!currentPassword || !newPassword) {
setPasswordError('Tous les champs sont requis')
return
}
if (newPassword.length < 6) {
setPasswordError('Le mot de passe doit faire au moins 6 caractères')
return
}
if (newPassword !== confirmPassword) {
setPasswordError('Les mots de passe ne correspondent pas')
return
}
setPasswordSaving(true)
try {
const res = await fetch('/api/auth/change-password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
})
if (res.ok) {
setPasswordMsg('Mot de passe modifié avec succès')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} else {
const data = await res.json()
setPasswordError(data.error || 'Erreur lors du changement')
}
} catch {
setPasswordError('Erreur serveur')
} finally {
setPasswordSaving(false)
}
}
const categories = Array.from(new Set(products.map(p => p.category)))
const categoryLabels: Record<string, string> = {
maintenance: 'Maintenance',
ksuite: 'KSuite',
hourly: 'Interventions',
travel: 'Déplacements',
}
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Paramètres</h2>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-4">
<User className="h-5 w-5 text-slate-500" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Mon compte</h3>
</div>
<div className="space-y-3 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom</label>
<input type="text" value="Pierre" disabled className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-500 text-sm cursor-not-allowed" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
<input type="email" value="pierre@secourit.fr" disabled className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-500 text-sm cursor-not-allowed" />
</div>
</div>
</div>
<hr className="border-slate-200 dark:border-slate-700 mb-4" />
<div className="flex items-center gap-3 mb-4">
<Lock className="h-5 w-5 text-slate-500" />
<h4 className="text-md font-semibold text-slate-900 dark:text-white">Changer le mot de passe</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe actuel</label>
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nouveau mot de passe</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmer</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
</div>
{passwordMsg && <p className="text-green-600 text-sm mt-2">{passwordMsg}</p>}
{passwordError && <p className="text-red-600 text-sm mt-2">{passwordError}</p>}
<button onClick={handleChangePassword} disabled={passwordSaving} className="mt-3 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{passwordSaving ? 'Enregistrement...' : 'Changer le mot de passe'}
</button>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-slate-500" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Tarifs & Produits</h3>
</div>
<button onClick={handleSeed} disabled={seeding} className="inline-flex items-center gap-2 px-3 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-sm font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${seeding ? 'animate-spin' : ''}`} />
Réinitialiser
</button>
</div>
{seedDone && (
<div className="bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 text-sm p-3 rounded-lg mb-4">
Tarifs par défaut créés avec succès !
</div>
)}
{loading ? (
<div className="text-center py-8 text-slate-500">Chargement...</div>
) : products.length === 0 ? (
<div className="text-center py-8">
<p className="text-slate-500 mb-3">Aucun tarif défini</p>
<button onClick={handleSeed} className="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
Créer les tarifs par défaut
</button>
</div>
) : (
<div className="space-y-6">
{categories.map(cat => (
<div key={cat}>
<h4 className="text-sm font-medium text-slate-500 uppercase mb-2">{categoryLabels[cat] || cat}</h4>
<div className="space-y-2">
{products.filter(p => p.category === cat).map(product => (
<div key={product.id} className="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-slate-700/50">
<div className="flex-1">
<p className="text-sm font-medium text-slate-900 dark:text-white">{product.label}</p>
<p className="text-xs text-slate-400">Unité: {product.unit}</p>
</div>
<div className="w-28">
<label className="block text-xs text-slate-500 mb-1">Prix d'achat</label>
<input type="number" step="0.01" value={product.costPrice} onChange={(e) => handleUpdate(product, 'costPrice', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div className="w-28">
<label className="block text-xs text-slate-500 mb-1">Prix de vente</label>
<input type="number" step="0.01" value={product.sellPrice} onChange={(e) => handleUpdate(product, 'sellPrice', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm" />
</div>
<div className="w-20 text-right">
<p className="text-xs text-slate-500 mb-1">Marge</p>
<p className={`text-sm font-medium ${product.sellPrice > 0 ? 'text-green-600' : 'text-slate-400'}`}>
{product.sellPrice > 0 ? `${(((product.sellPrice - product.costPrice) / product.sellPrice) * 100).toFixed(0)}%` : '—'}
</p>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={product.isActive} onChange={(e) => handleUpdate(product, 'isActive', e.target.checked)}
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500" />
<span className="text-xs text-slate-500">Actif</span>
</label>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, Users, Calculator, FileText, ShoppingCart, Settings,
ChevronLeft, Receipt, CheckSquare, Server, Monitor, Activity, Ticket,
} from 'lucide-react'
const commercialNav = [
{ name: 'Tableau de bord', icon: LayoutDashboard, view: 'dashboard' as const },
{ name: 'Todo', icon: CheckSquare, view: 'todo' as const },
{ name: 'Clients & Prospects', icon: Users, view: 'clients' as const },
{ name: 'Contrats', icon: FileText, view: 'contracts' as const },
{ name: 'Factures', icon: Receipt, view: 'invoices' as const, badge: true },
{ name: 'Ventes', icon: ShoppingCart, view: 'sales' as const, badge: true },
{ name: 'Simulation tarif', icon: Calculator, view: 'simulation' as const },
{ name: 'Devis en cours', icon: FileText, view: 'quotes' as const },
{ name: 'Paramètres', icon: Settings, view: 'settings' as const },
]
const techNav = [
{ name: 'Tableau de bord', icon: Monitor, view: 'serverDashboard' as const },
{ name: 'Serveurs', icon: Server, view: 'servers' as const },
{ name: 'Services', icon: Activity, view: 'services' as const },
{ name: 'Tickets', icon: Ticket, view: 'tickets' as const },
]
interface SidebarProps {
view: string
onViewChange: (view: string) => void
open: boolean
onClose: () => void
}
export function Sidebar({ view, onViewChange, open, onClose }: SidebarProps) {
const [notifCounts, setNotifCounts] = useState<Record<string, number>>({})
const techViews = ['serverDashboard', 'servers', 'services', 'tickets']
const [mode, setMode] = useState<'commercial' | 'tech'>(
techViews.includes(view) ? 'tech' : 'commercial'
)
useEffect(() => {
const fetchCount = async (endpoint: string, key: string) => {
try {
const r = await fetch(endpoint)
if (r.ok) {
const d = await r.json()
setNotifCounts(prev => ({ ...prev, [key]: d.count || 0 }))
}
} catch {}
}
fetchCount('/api/invoices/notifications', 'invoices')
fetchCount('/api/sales/notifications', 'sales')
}, [])
if (mode === 'tech' && !techViews.includes(view)) onViewChange('serverDashboard')
const targetMode = techViews.includes(view) ? 'tech' : 'commercial'
if (targetMode !== mode) setMode(targetMode)
const navItems = mode === 'commercial' ? commercialNav : techNav
return (
<>
<div
className={cn(
'fixed inset-0 z-40 bg-black/50 lg:hidden transition-opacity',
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
aria-hidden="true"
/>
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 transform transition-transform duration-200 ease-in-out flex flex-col lg:translate-x-0',
open ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Navigation principale"
>
<div className="flex h-16 items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700">
<img src="/logo.png" alt="SecourIT" className="h-8 mx-auto" />
<button
className="lg:hidden p-2 rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
onClick={onClose}
aria-label="Fermer le menu"
>
<ChevronLeft className="h-5 w-5" />
</button>
</div>
{/* Tabs Commercial / Serveurs */}
<div className="flex border-b border-slate-200 dark:border-slate-700">
<button
onClick={() => { setMode('commercial'); if (techViews.includes(view)) onViewChange('dashboard') }}
className={cn(
'flex-1 py-3 text-xs font-semibold uppercase tracking-wider transition-colors',
mode === 'commercial'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
)}
>
Commercial
</button>
<button
onClick={() => { setMode('tech'); if (!techViews.includes(view)) onViewChange('serverDashboard') }}
className={cn(
'flex-1 py-3 text-xs font-semibold uppercase tracking-wider transition-colors',
mode === 'tech'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
)}
>
TECH
</button>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto" role="navigation" aria-label="Menu principal">
{navItems.map((item) => {
const isActive = view === item.view
const itemCount = notifCounts[item.view] || 0
const showBadge = 'badge' in item && item.badge && itemCount > 0
return (
<button
key={item.name}
onClick={() => { onViewChange(item.view); onClose() }}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
)}
aria-current={isActive ? 'page' : undefined}
>
<item.icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
<span className="flex-1 text-left">{item.name}</span>
{showBadge && (
<span className="inline-flex items-center justify-center h-5 min-w-[1.25rem] px-1 rounded-full text-xs font-bold bg-red-500 text-white">
{itemCount}
</span>
)}
</button>
)
})}
</nav>
<div className="p-4 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 px-3 py-2">
<div className="h-8 w-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">P</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">Pierre</p>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">pierre@secourit.fr</p>
</div>
</div>
</div>
</aside>
</>
)
}
+415
View File
@@ -0,0 +1,415 @@
'use client'
import { useState, useEffect } from 'react'
import { Plus, Trash2, Save, Percent, MapPin, Hash, Eye } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
interface Client {
id: string
name: string
}
interface SimItem {
id: string
label: string
quantity: number
unitPrice: number
costPrice: number
}
export function SimulationContent({ editQuoteId, onSaved }: { editQuoteId?: string | null; onSaved?: () => void }) {
const [clients, setClients] = useState<Client[]>([])
const [selectedClient, setSelectedClient] = useState('')
const [items, setItems] = useState<SimItem[]>([])
const [discount, setDiscount] = useState(0)
const [travelKm, setTravelKm] = useState(0)
const [name, setName] = useState('')
const [quoteNumber, setQuoteNumber] = useState('')
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [loadingQuote, setLoadingQuote] = useState(false)
const [newClientName, setNewClientName] = useState('')
const [showCost, setShowCost] = useState(false)
useEffect(() => {
fetch('/api/clients').then(r => r.ok && r.json()).then(d => {
if (d?.clients) setClients(d.clients)
})
}, [])
useEffect(() => {
if (editQuoteId) {
setLoadingQuote(true)
fetch(`/api/quotes/${editQuoteId}`).then(r => r.ok && r.json()).then(d => {
if (d?.quote) {
const q = d.quote
setSelectedClient(q.clientId)
setName(q.name)
setQuoteNumber(q.quoteNumber || '')
setDiscount(q.discount || 0)
setTravelKm(q.travelKm || 0)
setNotes(q.notes || '')
setItems(q.items.map((i: any) => ({
id: Math.random().toString(36).substr(2, 9),
label: i.label,
quantity: i.quantity,
unitPrice: i.unitPrice,
costPrice: i.costPrice,
})))
}
}).finally(() => setLoadingQuote(false))
}
}, [editQuoteId])
const addItem = () => {
setItems([...items, { id: Math.random().toString(36).substr(2, 9), label: '', quantity: 1, unitPrice: 0, costPrice: 0 }])
}
const updateItem = (id: string, field: keyof SimItem, value: any) => {
setItems(items.map(item => item.id === id ? { ...item, [field]: value } : item))
}
const removeItem = (id: string) => {
setItems(items.filter(item => item.id !== id))
}
const subTotal = items.reduce((s, item) => s + (item.unitPrice || 0) * (item.quantity || 1), 0)
const totalCost = items.reduce((s, item) => s + (item.costPrice || 0) * (item.quantity || 1), 0)
const travelCost = travelKm * 0.5
const discountAmount = (subTotal * discount) / 100
const finalTotal = subTotal - discountAmount + travelCost
const marginTotal = (subTotal - discountAmount) - totalCost
const marginPercent = subTotal > 0 ? (marginTotal / subTotal) * 100 : 0
const handleSaveQuote = async () => {
if (!selectedClient && !newClientName.trim()) return
setSaving(true)
setSaved(false)
try {
let clientId = selectedClient
if (!clientId && newClientName.trim()) {
const res = await fetch('/api/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newClientName.trim(), type: 'PROSPECT' }),
})
if (res.ok) {
const data = await res.json()
clientId = data.client.id
setClients(prev => [...prev, data.client])
setSelectedClient(data.client.id)
}
}
const body = {
clientId,
quoteNumber: quoteNumber || null,
name: name || `Devis ${quoteNumber ? `n°${quoteNumber} - ` : ''}${new Date().toLocaleDateString('fr-FR')}`,
setupTotal: finalTotal,
monthlyTotal: 0,
costTotal: totalCost,
marginTotal,
discount,
travelKm,
travelCost,
notes,
items: items.map(item => ({
type: 'CUSTOM',
label: item.label,
unitPrice: item.unitPrice,
costPrice: item.costPrice,
quantity: item.quantity,
isRecurring: false,
setupPrice: 0,
monthlyPrice: 0,
})),
}
if (editQuoteId) {
await fetch(`/api/quotes/${editQuoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, status: 'DRAFT' }),
})
onSaved?.()
} else {
await fetch('/api/quotes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
setSaved(true)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">{editQuoteId ? 'Modifier le devis' : 'Nouveau devis'}</h2>
{loadingQuote && <p className="text-sm text-slate-500">Chargement du devis...</p>}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
<Hash className="h-4 w-4" />
Numéro de devis (Henrry)
</label>
<input
type="text"
value={quoteNumber}
onChange={(e) => setQuoteNumber(e.target.value)}
placeholder="ex: DEV-2024-001"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Client</label>
<div className="flex gap-2">
<select
value={selectedClient}
onChange={(e) => setSelectedClient(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
>
<option value="">Existant...</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<input
type="text"
placeholder="Nouveau client"
value={newClientName}
onChange={(e) => setNewClientName(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
Lignes du devis
<span className="text-sm font-normal text-slate-400 ml-2">{items.length} article{items.length !== 1 ? 's' : ''}</span>
</h3>
<button onClick={addItem}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-800/40 transition-colors">
<Plus className="h-3 w-3" />
Ajouter
</button>
</div>
{items.length === 0 ? (
<div className="text-center py-8 text-slate-400">
<p>Cliquez sur "Ajouter" pour saisir une ligne du devis</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 text-xs text-slate-500">
<th className="text-left py-2 pr-2 font-medium">Désignation</th>
<th className="text-center py-2 px-2 w-16 font-medium">Qté</th>
<th className="text-right py-2 px-2 w-28 font-medium">Prix unitaire</th>
<th className="text-right py-2 px-2 w-28 font-medium">Montant</th>
{showCost && <th className="text-right py-2 pl-2 w-24 font-medium">Coût achat</th>}
<th className="py-2 w-10"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-slate-100 dark:border-slate-700">
<td className="py-1.5 pr-2">
<input
type="text"
value={item.label}
onChange={(e) => updateItem(item.id, 'label', e.target.value)}
placeholder="Désignation"
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<input
type="number"
min={1}
value={item.quantity}
onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value) || 1)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm text-center"
/>
</td>
<td className="py-1.5 px-2">
<input
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => updateItem(item.id, 'unitPrice', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm text-right"
/>
</td>
<td className="py-1.5 px-2 text-right font-medium">
{formatCurrency((item.unitPrice || 0) * (item.quantity || 1))}
</td>
{showCost && (
<td className="py-1.5 pl-2">
<input
type="number"
step="0.01"
value={item.costPrice}
onChange={(e) => updateItem(item.id, 'costPrice', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm text-right"
/>
</td>
)}
<td className="py-1.5">
<button onClick={() => removeItem(item.id)} className="p-1 rounded text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<button
onClick={() => setShowCost(!showCost)}
className="mt-2 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{showCost ? 'Masquer' : 'Afficher'} les coûts d'achat
</button>
</div>
)}
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
<Percent className="h-4 w-4" />
Réduction
</label>
<div className="flex gap-2 items-center">
<input
type="number"
min={0}
max={100}
step={0.5}
value={discount}
onChange={(e) => setDiscount(parseFloat(e.target.value) || 0)}
className="w-20 px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm text-center"
/>
<span className="text-sm text-slate-500">%</span>
{discount > 0 && <span className="text-xs text-green-600">-{formatCurrency(discountAmount)}</span>}
</div>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
<MapPin className="h-4 w-4" />
Frais déplacement
</label>
<div className="flex gap-2 items-center">
<input
type="number"
min={0}
step={1}
value={travelKm}
onChange={(e) => setTravelKm(parseInt(e.target.value) || 0)}
className="w-20 px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm text-center"
/>
<span className="text-sm text-slate-500">km</span>
{travelKm > 0 && <span className="text-xs text-slate-500">= {formatCurrency(travelCost)}</span>}
</div>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Total</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Sous-total</span>
<span className="font-medium text-slate-900 dark:text-white">{formatCurrency(subTotal)}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>Réduction ({discount}%)</span>
<span>-{formatCurrency(discountAmount)}</span>
</div>
)}
{travelKm > 0 && (
<div className="flex justify-between text-sm">
<span className="text-slate-500">Déplacement</span>
<span className="font-medium text-slate-900 dark:text-white">{formatCurrency(travelCost)}</span>
</div>
)}
<hr className="border-slate-200 dark:border-slate-700" />
<div className="flex justify-between font-semibold text-xl">
<span>Total TTC</span>
<span className="text-primary-600">{formatCurrency(finalTotal)}</span>
</div>
</div>
</div>
{showCost && (
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-3">Marge</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Coût total achat</span>
<span className="font-medium text-red-500">{formatCurrency(totalCost)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Marge</span>
<span className={`font-medium ${marginTotal >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(marginTotal)} ({marginPercent.toFixed(1)}%)
</span>
</div>
</div>
</div>
)}
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">Enregistrer</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom du devis</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={quoteNumber ? `Devis n°${quoteNumber}` : `Devis - ${new Date().toLocaleDateString('fr-FR')}`}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm"
/>
</div>
<button
onClick={handleSaveQuote}
disabled={saving || items.length === 0 || (!selectedClient && !newClientName.trim())}
className="w-full inline-flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Enregistrement...' : editQuoteId ? 'Mettre à jour' : 'Enregistrer'}
</button>
{saved && (
<p className="text-green-600 text-sm text-center">{editQuoteId ? 'Devis mis à jour !' : 'Devis enregistré !'}</p>
)}
</div>
</div>
</div>
</div>
</div>
)
}
+671
View File
@@ -0,0 +1,671 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { Plus, MessageSquare, Send, ArrowLeft, Search, User, Calendar, Tag, Server, Building2, AlertCircle, Clock, CheckCircle2, Trash2 } from 'lucide-react'
type TicketStatus = 'new' | 'in_progress' | 'waiting_client' | 'waiting_supplier' | 'resolved' | 'closed'
type TicketPriority = 'low' | 'normal' | 'high' | 'urgent'
interface TicketData {
id: string
userId: string
clientId: string | null
serverId: string | null
subject: string
description: string
status: TicketStatus
priority: TicketPriority
source: string
emailFrom: string | null
assignedTo: string | null
ticketNumber: number
createdAt: string
updatedAt: string
client_name: string | null
client_company: string | null
client_email: string | null
server_name: string | null
server_ip: string | null
messages?: TicketMessageData[]
attachments?: TicketAttachmentData[]
}
interface TicketAttachmentData {
id: string
filename: string
contentType: string
contentId: string | null
messageId: string
createdAt: string
}
interface TicketMessageData {
id: string
ticketId: string
content: string
author: string
isInternal: boolean
createdAt: string
}
const statusConfig: Record<TicketStatus, { label: string; color: string; bg: string; border: string }> = {
new: { label: 'Nouveau', color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-l-blue-500' },
in_progress: { label: 'En cours', color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-l-amber-500' },
waiting_client: { label: 'Attente client', color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-900/20', border: 'border-l-purple-500' },
waiting_supplier: { label: 'Attente fournisseur', color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-900/20', border: 'border-l-orange-500' },
resolved: { label: 'Résolu', color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20', border: 'border-l-green-500' },
closed: { label: 'Fermé', color: 'text-slate-500 dark:text-slate-400', bg: 'bg-slate-50 dark:bg-slate-800', border: 'border-l-slate-400' },
}
const priorityConfig: Record<TicketPriority, { label: string; color: string }> = {
low: { label: 'Basse', color: 'text-slate-500' },
normal: { label: 'Normale', color: 'text-blue-600' },
high: { label: 'Haute', color: 'text-amber-600' },
urgent: { label: 'Urgente', color: 'text-red-600' },
}
const columnOrder: TicketStatus[] = ['new', 'in_progress', 'waiting_client', 'resolved', 'closed']
type SubView = 'kanban' | 'unacknowledged' | 'in_progress' | 'waiting' | 'closed' | 'detail'
const subNav = [
{ key: 'kanban' as const, label: 'Kanban', icon: CheckCircle2 },
{ key: 'unacknowledged' as const, label: 'Non acquittés', icon: AlertCircle },
{ key: 'in_progress' as const, label: 'En cours', icon: Clock },
{ key: 'waiting' as const, label: 'En attente', icon: Clock },
{ key: 'closed' as const, label: 'Fermés', icon: CheckCircle2 },
]
function statusFromSubView(sub: SubView): string | null {
if (sub === 'unacknowledged') return 'new'
if (sub === 'in_progress') return 'in_progress'
if (sub === 'waiting') return 'waiting_client,waiting_supplier'
if (sub === 'closed') return 'resolved,closed'
return null
}
export function TicketContent() {
const [subView, setSubView] = useState<SubView>('kanban')
const [tickets, setTickets] = useState<TicketData[]>([])
const [allTickets, setAllTickets] = useState<TicketData[]>([])
const [loading, setLoading] = useState(true)
const [selectedTicket, setSelectedTicket] = useState<TicketData | null>(null)
const [replyText, setReplyText] = useState('')
const [showForm, setShowForm] = useState(false)
const [clients, setClients] = useState<any[]>([])
const [servers, setServers] = useState<any[]>([])
const [search, setSearch] = useState('')
const [dragId, setDragId] = useState<string | null>(null)
const [form, setForm] = useState({ clientId: '', serverId: '', subject: '', description: '', priority: 'normal' as TicketPriority })
const [replyImages, setReplyImages] = useState<{ file: File; preview: string }[]>([])
const handleAddReplyImage = (files: FileList | null) => {
if (!files) return
const newImages = Array.from(files).map(file => ({
file,
preview: URL.createObjectURL(file),
}))
setReplyImages(prev => [...prev, ...newImages].slice(0, 5))
}
const handleRemoveReplyImage = (preview: string) => {
URL.revokeObjectURL(preview)
setReplyImages(prev => prev.filter(i => i.preview !== preview))
}
const fetchAllTickets = useCallback(async () => {
try {
const r = await fetch('/api/tickets')
if (r.ok) setAllTickets(await r.json())
} catch {}
}, [])
const fetchTickets = useCallback(async (statusFilter: string | null) => {
setLoading(true)
try {
const url = statusFilter ? `/api/tickets?status=${statusFilter}` : '/api/tickets'
const r = await fetch(url)
if (r.ok) setTickets(await r.json())
} catch {}
setLoading(false)
}, [])
const fetchClients = async () => {
try {
const r = await fetch('/api/clients')
if (r.ok) setClients(await r.json())
} catch {}
}
const fetchServers = async () => {
try {
const r = await fetch('/api/servers')
if (r.ok) setServers(await r.json())
} catch {}
}
useEffect(() => {
fetchAllTickets()
fetchClients()
fetchServers()
}, [])
useEffect(() => {
if (subView !== 'kanban' && subView !== 'detail') {
fetchTickets(statusFromSubView(subView))
}
}, [subView])
const handleViewTicket = async (ticket: TicketData) => {
try {
const r = await fetch(`/api/tickets/${ticket.id}`)
if (r.ok) {
setSelectedTicket(await r.json())
setSubView('detail')
}
} catch {}
}
const handleReply = async () => {
if (!replyText.trim() || !selectedTicket) return
try {
const images = await Promise.all(replyImages.map(async img => {
const buffer = await img.file.arrayBuffer()
const base64 = btoa(new Uint8Array(buffer).reduce((d, b) => d + String.fromCharCode(b), ''))
return {
filename: img.file.name,
contentType: img.file.type,
data: base64,
}
}))
await fetch(`/api/tickets/${selectedTicket.id}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: replyText, author: 'Moi', images }),
})
setReplyText('')
setReplyImages([])
handleViewTicket(selectedTicket)
} catch {}
}
const handleStatusChange = async (ticketId: string, status: string) => {
try {
await fetch(`/api/tickets/${ticketId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
if (subView === 'detail' && selectedTicket) {
handleViewTicket(selectedTicket)
} else if (subView === 'kanban') {
fetchAllTickets()
} else {
const sf = statusFromSubView(subView)
fetchTickets(sf)
}
} catch {}
}
const handlePriorityChange = async (ticketId: string, priority: string) => {
try {
await fetch(`/api/tickets/${ticketId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priority }),
})
const sf = statusFromSubView(subView)
fetchTickets(sf)
} catch {}
}
const handleDelete = async (ticketId: string) => {
if (!confirm('Supprimer ce ticket définitivement ?')) return
try {
await fetch(`/api/tickets/${ticketId}`, { method: 'DELETE' })
if (subView === 'detail') {
setSubView('kanban')
setSelectedTicket(null)
fetchAllTickets()
} else if (subView === 'kanban') {
fetchAllTickets()
} else {
const sf = statusFromSubView(subView)
fetchTickets(sf)
}
} catch {}
}
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.subject) return
try {
await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
setShowForm(false)
setForm({ clientId: '', serverId: '', subject: '', description: '', priority: 'normal' })
if (subView === 'kanban') fetchAllTickets()
else fetchTickets(statusFromSubView(subView))
} catch {}
}
const filteredTickets = tickets.filter(t =>
!search || t.subject.toLowerCase().includes(search.toLowerCase()) ||
(t.client_name?.toLowerCase() || '').includes(search.toLowerCase()) ||
(t.server_name?.toLowerCase() || '').includes(search.toLowerCase())
)
// === KANBAN ===
if (subView === 'kanban') {
const matchSearch = (t: TicketData) =>
!search ||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
(t.client_name?.toLowerCase() || '').includes(search.toLowerCase()) ||
(t.emailFrom?.toLowerCase() || '').includes(search.toLowerCase()) ||
(t.server_name?.toLowerCase() || '').includes(search.toLowerCase())
const columns = columnOrder.map(status => ({
status,
label: statusConfig[status].label,
tickets: allTickets.filter(t => t.status === status && matchSearch(t)),
}))
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Tickets — Kanban</h2>
<button onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
<Plus className="h-4 w-4" /> Nouveau ticket
</button>
</div>
{subNavTabs(subView, setSubView)}
{showForm && <TicketForm clients={clients} servers={servers} form={form} setForm={setForm} onSubmit={handleCreate} onCancel={() => setShowForm(false)} />}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder="Rechercher dans le kanban…"
className="w-full pl-10 pr-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[60vh]" style={{ scrollbarWidth: 'thin' }}>
{columns.map(col => (
<div key={col.status} className="flex-shrink-0 w-72 flex flex-col bg-slate-100 dark:bg-slate-800/50 rounded-xl"
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
onDrop={e => {
e.preventDefault()
if (dragId && dragId !== col.status) handleStatusChange(dragId, col.status)
setDragId(null)
}}>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h3 className={cn('text-sm font-semibold', statusConfig[col.status].color)}>{col.label}</h3>
<span className="text-xs text-slate-400 bg-slate-200 dark:bg-slate-700 px-2 py-0.5 rounded-full">{col.tickets.length}</span>
</div>
<div className="flex-1 p-2 space-y-2 overflow-y-auto">
{col.tickets.length === 0 ? (
<p className="text-xs text-slate-400 text-center py-4">Aucun ticket</p>
) : col.tickets.map(t => (
<KanbanCard key={t.id} ticket={t} onClick={handleViewTicket}
onDragStart={() => setDragId(t.id)}
onStatusChange={handleStatusChange} />
))}
</div>
</div>
))}
</div>
</div>
)
}
// === DETAIL ===
if (subView === 'detail' && selectedTicket) {
return (
<div className="space-y-6">
<button onClick={() => { setSubView('kanban'); setSelectedTicket(null); fetchAllTickets() }
} className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
<ArrowLeft className="h-4 w-4" /> Retour
</button>
<TicketDetailView
ticket={selectedTicket}
onStatusChange={handleStatusChange}
onReply={handleReply}
replyText={replyText}
setReplyText={setReplyText}
onDelete={handleDelete}
replyImages={replyImages}
onAddImage={handleAddReplyImage}
onRemoveImage={handleRemoveReplyImage}
/>
</div>
)
}
// === STATUS LIST VIEWS ===
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
Tickets — {subNav.find(n => n.key === subView)?.label}
</h2>
<button onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
<Plus className="h-4 w-4" /> Nouveau ticket
</button>
</div>
{subNavTabs(subView, setSubView)}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Rechercher..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
</div>
</div>
{showForm && <TicketForm clients={clients} servers={servers} form={form} setForm={setForm} onSubmit={handleCreate} onCancel={() => setShowForm(false)} />}
{loading ? (
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
) : filteredTickets.length === 0 ? (
<p className="text-slate-500 text-center py-8">{search ? 'Aucun résultat' : 'Aucun ticket.'}</p>
) : (
<div className="space-y-3">
{filteredTickets.map(ticket => <TicketCard key={ticket.id} ticket={ticket} onClick={handleViewTicket} />)}
</div>
)}
</div>
)
}
function subNavTabs(active: SubView, setter: (v: SubView) => void) {
return (
<div className="flex flex-wrap gap-1 border-b border-slate-200 dark:border-slate-700 pb-1">
{subNav.map(item => (
<button key={item.key} onClick={() => setter(item.key)}
className={cn(
'px-3 py-2 text-sm font-medium rounded-t-lg transition-colors',
active === item.key
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'
)}>
{item.label}
</button>
))}
</div>
)
}
function TicketForm({ clients, servers, form, setForm, onSubmit, onCancel }: {
clients: any[]
servers: any[]
form: { clientId: string; serverId: string; subject: string; description: string; priority: TicketPriority }
setForm: (f: any) => void
onSubmit: (e: React.FormEvent) => void
onCancel: () => void
}) {
return (
<form onSubmit={onSubmit} className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white">Nouveau ticket</h3>
<button type="button" onClick={onCancel} className="text-slate-400 hover:text-slate-600">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<select value={form.clientId} onChange={e => setForm((f: any) => ({ ...f, clientId: e.target.value }))}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="">Sans client</option>
{clients.map((c: any) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select value={form.serverId} onChange={e => setForm((f: any) => ({ ...f, serverId: e.target.value }))}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="">Sans serveur</option>
{servers.map((s: any) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<select value={form.priority} onChange={e => setForm((f: any) => ({ ...f, priority: e.target.value as TicketPriority }))}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="low">Basse</option>
<option value="normal">Normale</option>
<option value="high">Haute</option>
<option value="urgent">Urgente</option>
</select>
</div>
<input type="text" value={form.subject} onChange={e => setForm((f: any) => ({ ...f, subject: e.target.value }))} placeholder="Sujet *"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" required />
<textarea value={form.description} onChange={e => setForm((f: any) => ({ ...f, description: e.target.value }))} placeholder="Description" rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm" />
<button type="submit" className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium">
Créer le ticket
</button>
</form>
)
}
function KanbanCard({ ticket, onClick, onDragStart, onStatusChange }: {
ticket: TicketData
onClick: (t: TicketData) => void
onDragStart: () => void
onStatusChange: (id: string, status: string) => void
}) {
const sc = statusConfig[ticket.status] || { label: ticket.status, color: 'text-slate-500', bg: 'bg-slate-50', border: 'border-l-slate-400' }
return (
<div draggable onDragStart={onDragStart}
className="bg-white dark:bg-slate-700 rounded-lg border border-slate-200 dark:border-slate-600 border-l-4 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
style={{ borderLeftColor: 'var(--col-color, inherit)' }}
onClick={() => onClick(ticket)}>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<span className="text-xs text-slate-400 shrink-0">#{ticket.ticketNumber}</span>
<span className={cn('text-[10px] px-1.5 py-0.5 rounded font-medium', priorityConfig[ticket.priority]?.color || '', 'bg-slate-100 dark:bg-slate-600')}>
{priorityConfig[ticket.priority]?.label}
</span>
</div>
<p className="text-sm font-medium text-slate-900 dark:text-white leading-tight line-clamp-2">{ticket.subject}</p>
{(ticket.client_name || ticket.emailFrom) && <p className="text-xs text-slate-400 truncate">{ticket.client_name || ticket.emailFrom}</p>}
{ticket.description && <p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2">{ticket.description}</p>}
<div className="flex items-center justify-between text-[10px] text-slate-400">
<span>{new Date(ticket.createdAt).toLocaleDateString('fr-FR')}</span>
{ticket.source === 'email' && <span className="text-blue-500">Email</span>}
</div>
</div>
</div>
)
}
function TicketCard({ ticket, onClick }: { ticket: TicketData; onClick: (t: TicketData) => void }) {
const sc = statusConfig[ticket.status] || { label: ticket.status, color: 'text-slate-500', bg: 'bg-slate-100', border: 'border-l-slate-400' }
return (
<button onClick={() => onClick(ticket)}
className="w-full text-left bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-400">#{ticket.ticketNumber}</span>
<h4 className="font-medium text-slate-900 dark:text-white truncate">{ticket.subject}</h4>
<span className={cn('text-xs px-2 py-0.5 rounded-full', sc.bg, sc.color)}>{sc.label}</span>
<span className={cn('text-xs', priorityConfig[ticket.priority]?.color || '')}>{priorityConfig[ticket.priority]?.label}</span>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
{(ticket.client_name || ticket.emailFrom) && <span className="flex items-center gap-1"><User className="h-3 w-3" />{ticket.client_name || ticket.emailFrom}</span>}
{ticket.server_name && <span className="flex items-center gap-1"><Server className="h-3 w-3" />{ticket.server_name}</span>}
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(ticket.createdAt).toLocaleDateString('fr-FR')}</span>
</div>
</div>
</div>
</button>
)
}
function TicketDetailView({
ticket, onStatusChange, onReply, replyText, setReplyText, onDelete, replyImages, onAddImage, onRemoveImage,
}: {
ticket: TicketData
onStatusChange: (id: string, status: string) => void
onReply: () => void
replyText: string
setReplyText: (v: string) => void
onDelete: (id: string) => void
replyImages: { file: File; preview: string }[]
onAddImage: (files: FileList | null) => void
onRemoveImage: (preview: string) => void
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">#{ticket.ticketNumber} — {ticket.subject}</h2>
<p className="text-xs text-slate-400 mt-1">{new Date(ticket.createdAt).toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' })}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="font-medium text-slate-900 dark:text-white">Conversation</h3>
{ticket.messages?.length === 0 ? (
<p className="text-slate-500 text-sm">Aucun message</p>
) : (
ticket.messages?.map(m => (
<div key={m.id} className={cn('p-3 rounded-lg', m.isInternal ? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : 'bg-slate-50 dark:bg-slate-700/50')}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{m.author}</span>
<div className="flex items-center gap-2">
{m.isInternal && <span className="text-xs text-amber-600 dark:text-amber-400">Interne</span>}
<span className="text-xs text-slate-400">{new Date(m.createdAt).toLocaleString('fr-FR')}</span>
</div>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 whitespace-pre-wrap">{m.content}</p>
{ticket.attachments?.filter(a => a.messageId === m.id).map(att => (
<img key={att.id} src={`/api/tickets/${ticket.id}/attachments/${att.id}`} alt={att.filename}
className="mt-2 max-w-full rounded-lg border border-slate-200 dark:border-slate-600 cursor-pointer"
style={{ maxHeight: 400 }}
onClick={e => window.open((e.target as HTMLImageElement).src, '_blank')} />
))}
</div>
))
)}
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700">
<h3 className="font-medium text-slate-900 dark:text-white mb-3">Répondre</h3>
<textarea value={replyText} onChange={e => setReplyText(e.target.value)} placeholder="Votre message... (Ctrl+V pour coller une image)" rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm mb-3"
onPaste={e => {
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const file = items[i].getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length > 0) {
e.preventDefault()
const dt = new DataTransfer()
imageFiles.forEach(f => dt.items.add(f))
onAddImage(dt.files)
}
}} />
{replyImages.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{replyImages.map(img => (
<div key={img.preview} className="relative group">
<img src={img.preview} alt="preview" className="h-16 w-16 object-cover rounded-lg border border-slate-300 dark:border-slate-600" />
<button onClick={() => onRemoveImage(img.preview)}
className="absolute -top-1.5 -right-1.5 h-5 w-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">×</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 text-sm cursor-pointer">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
Image
<input type="file" accept="image/*" multiple className="hidden" onChange={e => onAddImage(e.target.files)} />
</label>
<button onClick={onReply} disabled={!replyText.trim()}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium disabled:opacity-50">
<Send className="h-4 w-4" /> Envoyer
</button>
</div>
</div>
</div>
<div className="space-y-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="font-medium text-slate-900 dark:text-white">Détails</h3>
<div className="space-y-3 text-sm">
<div>
<label className="text-xs text-slate-400 block">Statut</label>
<select value={ticket.status} onChange={e => onStatusChange(ticket.id, e.target.value)}
className="w-full mt-1 px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm">
<option value="new">Nouveau</option>
<option value="in_progress">En cours</option>
<option value="waiting_client">Attente client</option>
<option value="waiting_supplier">Attente fournisseur</option>
<option value="resolved">Résolu</option>
<option value="closed">Fermé</option>
</select>
</div>
<div>
<label className="text-xs text-slate-400 block">Priorité</label>
<p className={cn('mt-1 font-medium', priorityConfig[ticket.priority]?.color)}>{priorityConfig[ticket.priority]?.label}</p>
</div>
{ticket.client_name && (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-slate-400" />
<span>{ticket.client_name}</span>
</div>
)}
{ticket.server_name && (
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-slate-400" />
<span>{ticket.server_name}{ticket.server_ip ? ` (${ticket.server_ip})` : ''}</span>
</div>
)}
{ticket.emailFrom && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-slate-400" />
<span className="truncate">{ticket.emailFrom}</span>
</div>
)}
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-slate-400" />
<span>{ticket.source === 'email' ? 'Email' : 'Manuel'}</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-5 border border-slate-200 dark:border-slate-700 space-y-2">
<h3 className="font-medium text-slate-900 dark:text-white text-sm">Actions rapides</h3>
{ticket.status !== 'in_progress' && (
<button onClick={() => onStatusChange(ticket.id, 'in_progress')}
className="w-full text-left px-3 py-2 text-sm text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 rounded-lg hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors">
Prendre en charge
</button>
)}
{ticket.status !== 'resolved' && ticket.status !== 'closed' && (
<button onClick={() => onStatusChange(ticket.id, 'resolved')}
className="w-full text-left px-3 py-2 text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-900/20 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors">
Marquer résolu
</button>
)}
{ticket.status !== 'closed' && (
<button onClick={() => onStatusChange(ticket.id, 'closed')}
className="w-full text-left px-3 py-2 text-sm text-slate-600 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/50 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
Fermer
</button>
)}
<div className="pt-2 border-t border-slate-200 dark:border-slate-700">
<button onClick={() => onDelete(ticket.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors">
<Trash2 className="h-4 w-4" /> Supprimer le ticket
</button>
</div>
</div>
</div>
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Plus, Trash2, GripVertical, CheckCircle2, Circle, ChevronDown, ChevronRight } from 'lucide-react'
interface Todo {
id: string
text: string
position: number
done: boolean
}
export function TodoContent() {
const [todos, setTodos] = useState<Todo[]>([])
const [loading, setLoading] = useState(true)
const [newText, setNewText] = useState('')
const [showDone, setShowDone] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState('')
const editRef = useRef<HTMLInputElement>(null)
const dragItem = useRef<number | null>(null)
const dragOverItem = useRef<number | null>(null)
const fetchTodos = async () => {
setLoading(true)
try {
const res = await fetch('/api/todos')
if (res.ok) {
const data = await res.json()
setTodos(data.todos || [])
}
} finally {
setLoading(false)
}
}
useEffect(() => { fetchTodos() }, [])
const addTodo = async () => {
if (!newText.trim()) return
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText.trim() }),
})
if (res.ok) {
const data = await res.json()
setTodos(prev => [...prev, data.todo])
setNewText('')
} else {
const err = await res.text()
alert('Erreur: ' + err)
}
} catch (e: any) {
alert('Erreur réseau: ' + e.message)
}
}
const toggleDone = async (todo: Todo) => {
const newDone = !todo.done
setTodos(prev => prev.map(t => t.id === todo.id ? { ...t, done: newDone } : t))
await fetch('/api/todos', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: todo.id, done: newDone }),
})
}
const startEdit = (todo: Todo) => {
setEditingId(todo.id)
setEditText(todo.text)
setTimeout(() => editRef.current?.select(), 50)
}
const saveEdit = async () => {
if (!editingId || !editText.trim()) { setEditingId(null); return }
setTodos(prev => prev.map(t => t.id === editingId ? { ...t, text: editText.trim() } : t))
await fetch('/api/todos', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingId, text: editText.trim() }),
})
setEditingId(null)
}
const deleteTodo = async (id: string) => {
setTodos(prev => prev.filter(t => t.id !== id))
await fetch(`/api/todos?id=${id}`, { method: 'DELETE' })
}
const reorder = async (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return
const pending = todos.filter(t => !t.done)
const updated = [...pending]
const [moved] = updated.splice(fromIdx, 1)
updated.splice(toIdx, 0, moved)
const doneTodos = todos.filter(t => t.done)
const reordered = [...updated, ...doneTodos].map((t, i) => ({ ...t, position: i }))
setTodos(reordered)
for (const t of reordered) {
await fetch('/api/todos', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, position: t.position }),
})
}
}
const handleDragStart = (idx: number) => {
dragItem.current = idx
}
const handleDragEnter = (idx: number) => {
dragOverItem.current = idx
}
const handleDragEnd = () => {
if (dragItem.current !== null && dragOverItem.current !== null) {
reorder(dragItem.current, dragOverItem.current)
}
dragItem.current = null
dragOverItem.current = null
}
const pendingTodos = todos.filter(t => !t.done)
const doneTodos = todos.filter(t => t.done)
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Todo List</h2>
<div className="flex gap-2">
<input type="text" value={newText}
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
placeholder="Ajouter une tâche..."
className="flex-1 px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm"
/>
<button type="button" onClick={addTodo}
className="inline-flex items-center gap-1.5 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
<Plus className="h-4 w-4" />
Ajouter
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Chargement...</div>
) : pendingTodos.length === 0 && doneTodos.length === 0 ? (
<div className="text-center py-12 text-slate-500">Aucune tâche</div>
) : (
<div className="space-y-4">
{pendingTodos.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-slate-500 uppercase mb-2">À faire ({pendingTodos.length})</h3>
<div className="space-y-1">
{pendingTodos.map((todo, idx) => (
<div key={todo.id} draggable
onDragStart={() => handleDragStart(idx)}
onDragEnter={() => handleDragEnter(idx)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 cursor-grab active:cursor-grabbing transition-colors">
<GripVertical className="h-4 w-4 text-slate-400 flex-shrink-0" />
<button onClick={() => toggleDone(todo)}
className="flex-shrink-0 text-slate-400 hover:text-green-500">
<Circle className="h-5 w-5" />
</button>
{editingId === todo.id ? (
<input ref={editRef} type="text" value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') setEditingId(null); }}
className="flex-1 px-2 py-0.5 rounded border border-primary-300 dark:border-primary-600 bg-white dark:bg-slate-700 text-sm text-slate-900 dark:text-white outline-none"
/>
) : (
<span onDoubleClick={() => startEdit(todo)}
className="flex-1 text-sm text-slate-900 dark:text-white cursor-pointer hover:text-primary-600 dark:hover:text-primary-400">
{todo.text}
</span>
)}
<button onClick={() => deleteTodo(todo.id)}
className="p-1 rounded text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
)}
{doneTodos.length > 0 && (
<div>
<button onClick={() => setShowDone(!showDone)}
className="flex items-center gap-2 text-sm font-semibold text-slate-500 uppercase mb-2 hover:text-slate-700 dark:hover:text-slate-300">
{showDone ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
TODO OK ({doneTodos.length})
</button>
{showDone && (
<div className="space-y-1">
{doneTodos.map(todo => (
<div key={todo.id}
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 opacity-60">
<button onClick={() => toggleDone(todo)}
className="flex-shrink-0 text-green-500">
<CheckCircle2 className="h-5 w-5" />
</button>
{editingId === todo.id ? (
<input ref={editRef} type="text" value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') setEditingId(null); }}
className="flex-1 px-2 py-0.5 rounded border border-primary-300 dark:border-primary-600 bg-white dark:bg-slate-700 text-sm text-slate-900 dark:text-white outline-none"
/>
) : (
<span onDoubleClick={() => startEdit(todo)}
className="flex-1 text-sm line-through text-slate-400 cursor-pointer hover:text-primary-400">
{todo.text}
</span>
)}
<button onClick={() => deleteTodo(todo.id)}
className="p-1 rounded text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'
import { useRouter } from 'next/navigation'
interface User {
id: string
email: string
name: string | null
role: string
}
interface AuthContextType {
user: User | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/auth/me')
if (res.ok) {
const data = await res.json()
setUser(data.user)
} else {
setUser(null)
}
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const login = async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Erreur de connexion')
}
await refresh()
router.push('/')
}
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
router.push('/login')
}
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
+66
View File
@@ -0,0 +1,66 @@
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'your-super-secret-key-change-in-production-min-32-chars'
)
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export async function createToken(userId: string, email: string): Promise<string> {
return new SignJWT({ userId, email })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(JWT_SECRET)
}
export async function verifyToken(token: string): Promise<{ userId: string; email: string } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return { userId: payload.userId as string, email: payload.email as string }
} catch {
return null
}
}
export async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
if (!token) return null
return verifyToken(token)
}
export async function getCurrentUser() {
const session = await getSession()
if (!session) return null
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { id: true, email: true, name: true, role: true, infomaniakId: true }
})
return user
}
export async function setAuthCookie(token: string) {
const cookieStore = await cookies()
cookieStore.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
}
export async function clearAuthCookie() {
const cookieStore = await cookies()
cookieStore.delete('auth-token')
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '@/lib/prisma'
import { startOfMonth, eachMonthOfInterval, isBefore, isAfter } from 'date-fns'
export async function generateInvoicesForContract(contractId: string, startDate: Date, monthlyPrice: number) {
const startMonth = startOfMonth(new Date(startDate))
const today = startOfMonth(new Date())
const months = eachMonthOfInterval({ start: startMonth, end: today })
for (const month of months) {
if (isBefore(month, startMonth)) continue
if (isAfter(month, today)) continue
const existing: any[] = await prisma.$queryRawUnsafe(`
SELECT id FROM "Invoice" WHERE "contractId" = $1 AND month = $2
`, contractId, month)
if (existing.length > 0) continue
const id = `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
await prisma.$executeRawUnsafe(`
INSERT INTO "Invoice" (id, "contractId", month, amount, "updatedAt")
VALUES ($1, $2, $3, $4, $5)
`, id, contractId, month, monthlyPrice, new Date())
}
}
+21
View File
@@ -0,0 +1,21 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.infomaniak.ch',
port: parseInt(process.env.SMTP_PORT || '465'),
secure: true,
auth: {
user: process.env.SMTP_USER || 'support@secourit.fr',
pass: process.env.SMTP_PASS || '',
},
})
export async function sendEmail(to: string, subject: string, text: string, html?: string) {
await transporter.sendMail({
from: `"Secour IT" <support@secourit.fr>`,
to,
subject,
text,
html: html || text.replace(/\n/g, '<br>'),
})
}
+7
View File
@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+54
View File
@@ -0,0 +1,54 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
}).format(amount)
}
export function formatDate(date: string | Date): string {
return new Date(date).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function formatNumber(num: number): string {
return new Intl.NumberFormat('fr-FR').format(num)
}
export function calculateMargin(sellPrice: number, costPrice: number): number {
if (sellPrice === 0) return 0
return ((sellPrice - costPrice) / sellPrice) * 100
}
export function calculateTravelCost(km: number, ratePerKm: number = 0.5): number {
return km * ratePerKm
}
export const PRODUCT_COSTS = {
maintenance: {
poste: { cost: 1.20, sell: 9.60, remoteCost: 0.20 },
server: { cost: 4.00, sell: 25.20, remoteCost: 0.20 },
},
ksuite: {
standard: { cost: 1.58, sell: 0, storage: '50Go', emails: 2 },
business: { cost: 6.58, sell: 15.80, storage: '3To', emails: 5 },
entreprise: { cost: 12.41, sell: 0, storage: '6To', emails: 10 },
},
hourly: {
rate: 50,
minDuration: 0.5,
travelRate: 0.50,
},
} as const
export type ProductCosts = typeof PRODUCT_COSTS
+112
View File
@@ -0,0 +1,112 @@
import { ClientType, QuoteItemType } from '@prisma/client'
export interface QuoteCalculation {
setupTotal: number
monthlyTotal: number
costTotal: number
marginTotal: number
marginPercent: number
travelCost: number
discountAmount: number
finalSetupTotal: number
finalMonthlyTotal: number
}
export interface QuoteItemInput {
type: QuoteItemType
label: string
description?: string
unitPrice: number
costPrice: number
quantity: number
isRecurring: boolean
setupPrice: number
setupCost: number
monthlyPrice: number
monthlyCost: number
}
export interface SimulationFormData {
clientId?: string
clientName: string
items: SimulationItem[]
discount: number
travelKm: number
notes?: string
}
export interface SimulationItem {
id: string
type: QuoteItemType
label: string
quantity: number
unitPrice: number
costPrice: number
isRecurring: boolean
setupPrice: number
monthlyPrice: number
}
export interface DashboardStats {
monthlyRecurringRevenue: number
monthlyRecurringCost: number
monthlyMargin: number
monthlyMarginPercent: number
invoicedThisMonth: number
costThisMonth: number
salesCostThisMonth: number
marginThisMonth: number
activeContracts: number
activeClients: number
prospectsCount: number
topClients: TopClient[]
}
export interface TopClient {
id: string
name: string
monthlyRevenue: number
totalInvoiced: number
contractCount: number
}
export interface ChartDataPoint {
month: string
revenue: number
cost: number
margin: number
}
export const QUOTE_ITEM_LABELS: Record<QuoteItemType, string> = {
MAINTENANCE_POSTE: 'Maintenance Poste',
MAINTENANCE_SERVER: 'Maintenance Serveur',
KS_SUITE_STANDARD: 'KSuite Standard',
KS_SUITE_BUSINESS: 'KSuite Business',
KS_SUITE_ENTREPRISE: 'KSuite Entreprise',
HOURLY_INTERVENTION: 'Intervention Horaire',
TRAVEL: 'Déplacement',
CUSTOM: 'Personnalisé',
}
export const CLIENT_TYPE_LABELS: Record<ClientType, string> = {
PROSPECT: 'Prospect',
CLIENT: 'Client',
}
export const QUOTE_STATUS_LABELS: Record<string, string> = {
DRAFT: 'Brouillon',
PROPOSAL: 'Proposition en cours',
SENT: 'Envoyé',
ACCEPTED: 'Accepté',
REJECTED: 'Refusé',
EXPIRED: 'Expiré',
}
export const CONTRACT_STATUS_LABELS: Record<string, string> = {
PROPOSAL: 'Proposition en cours',
ACTIVE: 'Actif',
PAUSED: 'En pause',
CANCELLED: 'Annulé',
EXPIRED: 'Expiré',
}
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
},
},
},
plugins: [],
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/python3
import sys, json, os, urllib.request, urllib.error, base64
from email import message_from_bytes
from email.utils import parseaddr
from email.header import decode_header
def decode_str(val):
if not val:
return ''
parts = decode_header(val)
result = []
for part, charset in parts:
if isinstance(part, bytes):
try:
result.append(part.decode(charset or 'utf-8', errors='replace'))
except (LookupError, ValueError):
result.append(part.decode('utf-8', errors='replace'))
else:
result.append(part)
return ' '.join(result).strip()
def decode_payload(part):
payload = part.get_payload(decode=True)
if not payload:
return ''
charset = part.get_content_charset() or 'utf-8'
try:
return payload.decode(charset, errors='replace').strip()
except (LookupError, ValueError):
return payload.decode('utf-8', errors='replace').strip()
try:
msg = message_from_bytes(sys.stdin.buffer.read())
from_hdr = parseaddr(msg['From'] or '')[1]
subject_hdr = decode_str(msg['Subject'] or '')
text_body = ''
html_body = ''
attachments = []
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == 'text/plain' and not text_body:
text_body = decode_payload(part)
elif ct == 'text/html' and not html_body:
html_body = decode_payload(part)
elif ct.startswith('image/'):
payload = part.get_payload(decode=True)
if payload:
cid = part.get('Content-ID', '')
if cid:
cid = cid.strip('<>')
filename = part.get_filename() or 'image.png'
attachments.append({
'filename': filename,
'contentType': ct,
'contentId': cid or None,
'data': base64.b64encode(payload).decode('ascii'),
})
else:
text_body = decode_payload(msg)
if not text_body and html_body:
text_body = html_body
token = ''
with open('/var/www/gestion-app/.env') as f:
for line in f:
if line.startswith('SERVER_MONITOR_SECRET='):
token = line.split('=', 1)[1].strip()
if not token:
sys.exit(1)
payload = {
'from': from_hdr,
'subject': subject_hdr,
'text': text_body,
}
if html_body:
payload['html'] = html_body
if attachments:
payload['attachments'] = attachments
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
'http://localhost:3005/api/tickets/email?token={}'.format(token),
data=data, headers={'Content-Type': 'application/json'}, method='POST'
)
urllib.request.urlopen(req, timeout=30)
sys.exit(0)
except Exception as e:
with open('/tmp/ticket_handler_error.log', 'w') as f:
f.write('Error: {}\n'.format(e))
f.write('Type: {}\n'.format(type(e).__name__))
import traceback
traceback.print_exc(file=f)
sys.exit(1)
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}