Initial commit: SecourIT Gestion

This commit is contained in:
2026-08-23 09:06:18 +00:00
commit cab93c7f7c
85 changed files with 15801 additions and 0 deletions
+413
View File
@@ -0,0 +1,413 @@
'use client'
import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, X, FileText } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
interface Contract {
id: string
name: string
client: { id: string; name: string }
startDate: string
endDate: string | null
setupPrice: number
setupCost: number
monthlyPrice: number
monthlyCost: number
status: string
billingDay: number
notes: string | null
_count?: { invoices: number }
}
export function ContractsContent() {
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<Contract | null>(null)
const [clients, setClients] = useState<{ id: string; name: string }[]>([])
const [form, setForm] = useState({
clientId: '', name: '', startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: '', setupPrice: '', setupCost: '', monthlyPrice: '', monthlyCost: '',
billingDay: '1', status: 'ACTIVE', notes: '',
})
const [saving, setSaving] = useState(false)
useEffect(() => { fetchContracts(); fetchClients() }, [])
const fetchContracts = async () => {
setLoading(true)
try {
const res = await fetch('/api/contracts')
if (res.ok) {
const data = await res.json()
setContracts(data.contracts)
}
} finally {
setLoading(false)
}
}
const fetchClients = async () => {
try {
const res = await fetch('/api/clients')
if (res.ok) {
const data = await res.json()
const sorted = (data.clients || []).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name))
setClients(sorted)
}
} catch {}
}
const openNew = () => {
setEditing(null)
setForm({
clientId: '', name: '', startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: '', setupPrice: '', setupCost: '', monthlyPrice: '', monthlyCost: '',
billingDay: '1', status: 'ACTIVE', notes: '',
})
setShowForm(true)
}
const openEdit = (contract: Contract) => {
setEditing(contract)
setForm({
clientId: contract.client.id,
name: contract.name,
startDate: format(new Date(contract.startDate), 'yyyy-MM-dd'),
endDate: contract.endDate ? format(new Date(contract.endDate), 'yyyy-MM-dd') : '',
setupPrice: contract.setupPrice.toString(),
setupCost: contract.setupCost.toString(),
monthlyPrice: contract.monthlyPrice.toString(),
monthlyCost: contract.monthlyCost.toString(),
billingDay: contract.billingDay.toString(),
status: contract.status,
notes: contract.notes || '',
})
setShowForm(true)
}
const handleSave = async () => {
if (!form.clientId || !form.name.trim()) return
setSaving(true)
try {
const body = {
clientId: form.clientId,
name: form.name,
startDate: form.startDate,
endDate: form.endDate || null,
setupPrice: parseFloat(form.setupPrice) || 0,
setupCost: parseFloat(form.setupCost) || 0,
monthlyPrice: parseFloat(form.monthlyPrice) || 0,
monthlyCost: parseFloat(form.monthlyCost) || 0,
billingDay: parseInt(form.billingDay) || 1,
status: form.status,
notes: form.notes || null,
}
if (editing) {
await fetch(`/api/contracts/${editing.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
} else {
await fetch('/api/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
setShowForm(false)
setEditing(null)
fetchContracts()
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Supprimer ce contrat ?')) return
try {
await fetch(`/api/contracts/${id}`, { method: 'DELETE' })
fetchContracts()
} catch {}
}
const statusColors: Record<string, string> = {
PROPOSAL: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
ACTIVE: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
PAUSED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
CANCELLED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
EXPIRED: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
}
const statusLabels: Record<string, string> = {
PROPOSAL: 'Proposition en cours', ACTIVE: 'Actif', PAUSED: 'En pause', CANCELLED: 'Annulé', EXPIRED: 'Expiré',
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Contrats</h2>
<button
onClick={openNew}
className="inline-flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Plus className="h-4 w-4" />
Nouveau contrat
</button>
</div>
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl w-full max-w-lg p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{editing ? 'Modifier' : 'Nouveau'} contrat
</h3>
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-5 w-5 text-slate-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Client *</label>
<select
value={form.clientId}
onChange={(e) => setForm({ ...form, clientId: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
>
<option value="">Sélectionner un client</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom du contrat *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date début</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date fin</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Prix mise en place</label>
<input
type="number"
step="0.01"
value={form.setupPrice}
onChange={(e) => setForm({ ...form, setupPrice: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Coût mise en place</label>
<input
type="number"
step="0.01"
value={form.setupCost}
onChange={(e) => setForm({ ...form, setupCost: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Prix mensuel</label>
<input
type="number"
step="0.01"
value={form.monthlyPrice}
onChange={(e) => setForm({ ...form, monthlyPrice: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Coût mensuel</label>
<input
type="number"
step="0.01"
value={form.monthlyCost}
onChange={(e) => setForm({ ...form, monthlyCost: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Jour de facturation</label>
<input
type="number"
min={1}
max={31}
value={form.billingDay}
onChange={(e) => setForm({ ...form, billingDay: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Statut</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
>
<option value="PROPOSAL">Proposition en cours</option>
<option value="ACTIVE">Actif</option>
<option value="PAUSED">En pause</option>
<option value="CANCELLED">Annulé</option>
<option value="EXPIRED">Expiré</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={saving || !form.clientId || !form.name.trim()}
className="px-4 py-2 text-sm font-medium bg-primary-600 hover:bg-primary-700 text-white rounded-lg disabled:opacity-50"
>
{saving ? 'Enregistrement...' : editing ? 'Modifier' : 'Créer'}
</button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Total revenus mensuels</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(contracts.reduce((s, c) => s + (c.status === 'ACTIVE' ? c.monthlyPrice : 0), 0))}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Total coûts mensuels</p>
<p className="text-xl font-bold text-red-500">{formatCurrency(contracts.reduce((s, c) => s + (c.status === 'ACTIVE' ? c.monthlyCost : 0), 0))}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Marge mensuelle</p>
<p className="text-xl font-bold text-primary-600">
{formatCurrency(contracts.reduce((s, c) => s + (c.status === 'ACTIVE' ? c.monthlyPrice - c.monthlyCost : 0), 0))}
</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500">Contrats actifs</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{contracts.filter(c => c.status === 'ACTIVE').length}</p>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Contrat</th>
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Client</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Mensuel</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Coût</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Marge</th>
<th className="text-center px-4 py-3 text-xs font-medium text-slate-500 uppercase">Statut</th>
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{loading ? (
<tr>
<td colSpan={7} className="text-center py-8 text-slate-500">Chargement...</td>
</tr>
) : contracts.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-slate-500">Aucun contrat</td>
</tr>
) : (
contracts.map((contract) => {
const margin = contract.monthlyPrice - contract.monthlyCost
const marginPct = contract.monthlyPrice > 0 ? (margin / contract.monthlyPrice) * 100 : 0
return (
<tr key={contract.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-white">{contract.name}</p>
<p className="text-xs text-slate-400">
Début: {format(new Date(contract.startDate), 'dd/MM/yyyy', { locale: fr })}
{contract.endDate && ` - Fin: ${format(new Date(contract.endDate), 'dd/MM/yyyy', { locale: fr })}`}
</p>
</td>
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">
{contract.client.name}
</td>
<td className="px-4 py-3 text-center font-medium text-green-600">
{formatCurrency(contract.monthlyPrice)}
</td>
<td className="px-4 py-3 text-center text-sm text-slate-600 dark:text-slate-300">
{formatCurrency(contract.monthlyCost)}
</td>
<td className="px-4 py-3 text-center">
<span className={`font-medium ${margin >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{formatCurrency(margin)}
<span className="text-xs ml-1">({marginPct.toFixed(0)}%)</span>
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[contract.status] || ''}`}>
{statusLabels[contract.status] || contract.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEdit(contract)} className="p-1.5 rounded text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<Edit2 className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(contract.id)} className="p-1.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}