Files
gestion/src/components/SalesContent.tsx
T

535 lines
25 KiB
TypeScript

'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>
)
}