414 lines
20 KiB
TypeScript
414 lines
20 KiB
TypeScript
'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>
|
|
)
|
|
}
|