Initial commit: SecourIT Gestion
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user