Initial commit: SecourIT Gestion
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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)
|
||||||
Vendored
+5
@@ -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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
serverActions: { bodySizeLimit: '2mb' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
Generated
+6731
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
Executable
+20
@@ -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}"
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardClient } from '@/components/DashboardClient'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <DashboardClient />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>'),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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é',
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
Executable
+99
@@ -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)
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user