Initial commit
This commit is contained in:
996
src/components/pos/POSModule.tsx
Normal file
996
src/components/pos/POSModule.tsx
Normal file
@@ -0,0 +1,996 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Search,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
User,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
FileText,
|
||||
X,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
|
||||
// Types
|
||||
interface Produit {
|
||||
id: string
|
||||
reference: string
|
||||
designation: string
|
||||
categorie: string
|
||||
prixVenteTTC: number
|
||||
tva: number
|
||||
stock: number
|
||||
marque?: string
|
||||
typeMonture?: string
|
||||
}
|
||||
|
||||
interface Client {
|
||||
id: string
|
||||
nom: string
|
||||
prenom: string
|
||||
email?: string
|
||||
telephone: string
|
||||
}
|
||||
|
||||
interface CartItem {
|
||||
produit: Produit
|
||||
quantite: number
|
||||
prixUnitaireHT: number
|
||||
prixUnitaireTTC: number
|
||||
remise: number
|
||||
montantHT: number
|
||||
montantTTC: number
|
||||
}
|
||||
|
||||
interface Paiement {
|
||||
mode: 'ESPECES' | 'CARTE' | 'CHEQUE' | 'VIREMENT' | 'BON_CAISSE'
|
||||
montant: number
|
||||
reference?: string
|
||||
}
|
||||
|
||||
interface Vente {
|
||||
id: string
|
||||
numero: string
|
||||
date: string
|
||||
statut: 'INITIEE' | 'PAYEE' | 'ANNULEE' | 'REMBOURSEE'
|
||||
montantHT: number
|
||||
montantTVA: number
|
||||
montantTTC: number
|
||||
remise: number
|
||||
client?: Client
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const calculateHTFromTTC = (ttc: number, tvaRate: number) => {
|
||||
return ttc / (1 + tvaRate / 100)
|
||||
}
|
||||
|
||||
const calculateTTCFromHT = (ht: number, tvaRate: number) => {
|
||||
return ht * (1 + tvaRate / 100)
|
||||
}
|
||||
|
||||
export default function POSModule() {
|
||||
// State
|
||||
const [products, setProducts] = useState<Produit[]>([])
|
||||
const [filteredProducts, setFilteredProducts] = useState<Produit[]>([])
|
||||
const [clients, setClients] = useState<Client[]>([])
|
||||
const [cart, setCart] = useState<CartItem[]>([])
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [globalDiscount, setGlobalDiscount] = useState(0)
|
||||
const [payments, setPayments] = useState<Paiement[]>([])
|
||||
const [currentPayment, setCurrentPayment] = useState<Paiement>({
|
||||
mode: 'ESPECES',
|
||||
montant: 0
|
||||
})
|
||||
const [showCustomerDialog, setShowCustomerDialog] = useState(false)
|
||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
|
||||
const [showInvoiceDialog, setShowInvoiceDialog] = useState(false)
|
||||
const [currentSale, setCurrentSale] = useState<Vente | null>(null)
|
||||
const [newClient, setNewClient] = useState({
|
||||
nom: '',
|
||||
prenom: '',
|
||||
email: '',
|
||||
telephone: ''
|
||||
})
|
||||
const [activeTab, setActiveTab] = useState<'pos' | 'history'>('pos')
|
||||
const [salesHistory, setSalesHistory] = useState<Vente[]>([])
|
||||
|
||||
// Calculations
|
||||
const cartTotals = cart.reduce((acc, item) => ({
|
||||
ht: acc.ht + item.montantHT,
|
||||
ttc: acc.ttc + item.montantTTC
|
||||
}), { ht: 0, ttc: 0 })
|
||||
|
||||
const totalTVA = cartTotals.ttc - cartTotals.ht
|
||||
const globalDiscountAmount = (cartTotals.ttc * globalDiscount) / 100
|
||||
const finalTTC = cartTotals.ttc - globalDiscountAmount
|
||||
const totalPaid = payments.reduce((sum, p) => sum + p.montant, 0)
|
||||
const remainingToPay = finalTTC - totalPaid
|
||||
|
||||
// Load functions
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pos/products')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setProducts(data)
|
||||
setFilteredProducts(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading products:', error)
|
||||
toast({
|
||||
title: 'Erreur',
|
||||
description: 'Impossible de charger les produits',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadClients = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pos/clients')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setClients(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading clients:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSalesHistory = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pos/sales')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSalesHistory(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading sales history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed sample data
|
||||
const seedSampleData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/pos/seed', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: 'Données ajoutées',
|
||||
description: 'Les données de test ont été créées avec succès'
|
||||
})
|
||||
loadProducts()
|
||||
loadClients()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error seeding data:', error)
|
||||
toast({
|
||||
title: 'Erreur',
|
||||
description: 'Impossible de créer les données de test',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Load products on mount
|
||||
useEffect(() => {
|
||||
loadProducts()
|
||||
loadClients()
|
||||
loadSalesHistory()
|
||||
}, [])
|
||||
|
||||
// Search products
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredProducts(products)
|
||||
return
|
||||
}
|
||||
const query = searchQuery.toLowerCase()
|
||||
const filtered = products.filter(p =>
|
||||
p.reference.toLowerCase().includes(query) ||
|
||||
p.designation.toLowerCase().includes(query) ||
|
||||
p.marque?.toLowerCase().includes(query) ||
|
||||
p.categorie.toLowerCase().includes(query)
|
||||
)
|
||||
setFilteredProducts(filtered)
|
||||
}, [searchQuery, products])
|
||||
|
||||
// Add to cart
|
||||
const addToCart = (product: Produit) => {
|
||||
if (product.stock <= 0) {
|
||||
toast({
|
||||
title: 'Stock épuisé',
|
||||
description: 'Ce produit n\'est plus en stock',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCart(prevCart => {
|
||||
const existingItem = prevCart.find(item => item.produit.id === product.id)
|
||||
const maxQty = existingItem ? existingItem.quantite + 1 : 1
|
||||
|
||||
if (maxQty > product.stock) {
|
||||
toast({
|
||||
title: 'Stock insuffisant',
|
||||
description: `Seulement ${product.stock} unités disponibles`,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return prevCart
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
const updatedItem = {
|
||||
...existingItem,
|
||||
quantite: maxQty,
|
||||
montantHT: existingItem.prixUnitaireHT * maxQty,
|
||||
montantTTC: existingItem.prixUnitaireTTC * maxQty
|
||||
}
|
||||
return prevCart.map(item =>
|
||||
item.produit.id === product.id ? updatedItem : item
|
||||
)
|
||||
}
|
||||
|
||||
const prixHT = calculateHTFromTTC(product.prixVenteTTC, product.tva)
|
||||
const newItem: CartItem = {
|
||||
produit: product,
|
||||
quantite: 1,
|
||||
prixUnitaireHT: prixHT,
|
||||
prixUnitaireTTC: product.prixVenteTTC,
|
||||
remise: 0,
|
||||
montantHT: prixHT,
|
||||
montantTTC: product.prixVenteTTC
|
||||
}
|
||||
return [...prevCart, newItem]
|
||||
})
|
||||
}
|
||||
|
||||
// Update cart item quantity
|
||||
const updateQuantity = (productId: string, newQuantity: number) => {
|
||||
if (newQuantity < 1) return
|
||||
|
||||
const item = cart.find(i => i.produit.id === productId)
|
||||
if (!item) return
|
||||
|
||||
if (newQuantity > item.produit.stock) {
|
||||
toast({
|
||||
title: 'Stock insuffisant',
|
||||
description: `Seulement ${item.produit.stock} unités disponibles`,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCart(prevCart =>
|
||||
prevCart.map(item => {
|
||||
if (item.produit.id === productId) {
|
||||
return {
|
||||
...item,
|
||||
quantite: newQuantity,
|
||||
montantHT: item.prixUnitaireHT * newQuantity,
|
||||
montantTTC: item.prixUnitaireTTC * newQuantity
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Remove from cart
|
||||
const removeFromCart = (productId: string) => {
|
||||
setCart(prevCart => prevCart.filter(item => item.produit.id !== productId))
|
||||
}
|
||||
|
||||
// Clear cart
|
||||
const clearCart = () => {
|
||||
setCart([])
|
||||
setSelectedClient(null)
|
||||
setGlobalDiscount(0)
|
||||
setPayments([])
|
||||
setCurrentPayment({ mode: 'ESPECES', montant: 0 })
|
||||
}
|
||||
|
||||
// Create new client
|
||||
const handleCreateClient = async () => {
|
||||
if (!newClient.nom || !newClient.prenom || !newClient.telephone) {
|
||||
toast({
|
||||
title: 'Champs requis',
|
||||
description: 'Veuillez remplir tous les champs obligatoires',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pos/clients', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newClient)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const createdClient = await response.json()
|
||||
setSelectedClient(createdClient)
|
||||
setClients([...clients, createdClient])
|
||||
setNewClient({ nom: '', prenom: '', email: '', telephone: '' })
|
||||
setShowCustomerDialog(false)
|
||||
toast({
|
||||
title: 'Client créé',
|
||||
description: 'Le client a été créé avec succès'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating client:', error)
|
||||
toast({
|
||||
title: 'Erreur',
|
||||
description: 'Impossible de créer le client',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add payment
|
||||
const addPayment = () => {
|
||||
if (currentPayment.montant <= 0) {
|
||||
toast({
|
||||
title: 'Montant invalide',
|
||||
description: 'Veuillez entrer un montant valide',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (totalPaid + currentPayment.montant > finalTTC) {
|
||||
toast({
|
||||
title: 'Montant excessif',
|
||||
description: 'Le paiement dépasse le montant dû',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setPayments([...payments, currentPayment])
|
||||
setCurrentPayment({ mode: 'ESPECES', montant: 0 })
|
||||
}
|
||||
|
||||
// Remove payment
|
||||
const removePayment = (index: number) => {
|
||||
setPayments(payments.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// Complete sale
|
||||
const completeSale = async () => {
|
||||
if (cart.length === 0) {
|
||||
toast({
|
||||
title: 'Panier vide',
|
||||
description: 'Veuillez ajouter des produits au panier',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (remainingToPay > 0.01) {
|
||||
toast({
|
||||
title: 'Paiement incomplet',
|
||||
description: `Il reste ${remainingToPay.toFixed(2)} € à payer`,
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pos/sales', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientId: selectedClient?.id || null,
|
||||
lignes: cart.map(item => ({
|
||||
produitId: item.produit.id,
|
||||
quantite: item.quantite,
|
||||
prixUnitaireHT: item.prixUnitaireHT,
|
||||
prixUnitaireTTC: item.prixUnitaireTTC,
|
||||
remise: item.remise,
|
||||
montantHT: item.montantHT,
|
||||
montantTTC: item.montantTTC
|
||||
})),
|
||||
paiements: payments,
|
||||
remise: globalDiscount,
|
||||
montantHT: cartTotals.ht - (globalDiscountAmount / (1 + 0.2)),
|
||||
montantTVA: totalTVA,
|
||||
montantTTC: finalTTC,
|
||||
notes: ''
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const sale = await response.json()
|
||||
setCurrentSale(sale)
|
||||
setShowInvoiceDialog(true)
|
||||
loadSalesHistory()
|
||||
loadProducts()
|
||||
clearCart()
|
||||
toast({
|
||||
title: 'Vente enregistrée',
|
||||
description: `Vente ${sale.numero} complétée avec succès`
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error completing sale:', error)
|
||||
toast({
|
||||
title: 'Erreur',
|
||||
description: 'Impossible d\'enregistrer la vente',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (statut: string) => {
|
||||
const variants: Record<string, { color: string; label: string }> = {
|
||||
'INITIEE': { color: 'bg-yellow-500', label: 'Initiée' },
|
||||
'PAYEE': { color: 'bg-green-500', label: 'Payée' },
|
||||
'ANNULEE': { color: 'bg-red-500', label: 'Annulée' },
|
||||
'REMBOURSEE': { color: 'bg-blue-500', label: 'Remboursée' }
|
||||
}
|
||||
const status = variants[statut] || variants['INITIEE']
|
||||
return <Badge className={status.color}>{status.label}</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'pos' | 'history')}>
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="pos">Point de Vente</TabsTrigger>
|
||||
<TabsTrigger value="history">Historique</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pos" className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column: Products */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Recherche Produits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
placeholder="Référence, désignation, marque..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-2">
|
||||
{filteredProducts.map(product => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md hover:border-primary ${
|
||||
product.stock <= 0 ? 'opacity-50' : ''
|
||||
}`}
|
||||
onClick={() => product.stock > 0 && addToCart(product)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{product.designation}</p>
|
||||
<p className="text-xs text-gray-500">{product.reference}</p>
|
||||
{product.marque && (
|
||||
<p className="text-xs text-gray-400">{product.marque}</p>
|
||||
)}
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
{product.categorie}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-primary">
|
||||
{product.prixVenteTTC.toFixed(2)} €
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Stock: {product.stock}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{filteredProducts.length === 0 && (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<p className="text-gray-500">
|
||||
{products.length === 0
|
||||
? 'Aucun produit dans la base de données'
|
||||
: 'Aucun produit trouvé'}
|
||||
</p>
|
||||
{products.length === 0 && (
|
||||
<Button onClick={seedSampleData} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter des données de test
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Middle & Right columns: Cart and Checkout */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Customer Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Select
|
||||
value={selectedClient?.id || ''}
|
||||
onValueChange={(value) => {
|
||||
const client = clients.find(c => c.id === value)
|
||||
setSelectedClient(client || null)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Sélectionner un client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(client => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.prenom} {client.nom} - {client.telephone}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog open={showCustomerDialog} onOpenChange={setShowCustomerDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nouveau Client
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un nouveau client</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remplissez les informations du client
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Nom *</Label>
|
||||
<Input
|
||||
value={newClient.nom}
|
||||
onChange={(e) => setNewClient({ ...newClient, nom: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Prénom *</Label>
|
||||
<Input
|
||||
value={newClient.prenom}
|
||||
onChange={(e) => setNewClient({ ...newClient, prenom: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Téléphone *</Label>
|
||||
<Input
|
||||
value={newClient.telephone}
|
||||
onChange={(e) => setNewClient({ ...newClient, telephone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newClient.email}
|
||||
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleCreateClient} className="w-full">
|
||||
Créer le client
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{selectedClient && (
|
||||
<Button variant="ghost" onClick={() => setSelectedClient(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selectedClient && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-medium">{selectedClient.prenom} {selectedClient.nom}</span>
|
||||
{selectedClient.email && <span> - {selectedClient.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Shopping Cart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Panier ({cart.length} articles)
|
||||
</div>
|
||||
{cart.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearCart}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cart.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Le panier est vide
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Produit</TableHead>
|
||||
<TableHead className="text-center">Qté</TableHead>
|
||||
<TableHead className="text-right">Prix Unit.</TableHead>
|
||||
<TableHead className="text-right">Total TTC</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cart.map(item => (
|
||||
<TableRow key={item.produit.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{item.produit.designation}</p>
|
||||
<p className="text-xs text-gray-500">{item.produit.reference}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => updateQuantity(item.produit.id, item.quantite - 1)}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm">{item.quantite}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => updateQuantity(item.produit.id, item.quantite + 1)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{item.prixUnitaireTTC.toFixed(2)} €
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{item.montantTTC.toFixed(2)} €
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeFromCart(item.produit.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Total HT:</span>
|
||||
<span>{cartTotals.ht.toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>TVA:</span>
|
||||
<span>{totalTVA.toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<span className="text-sm">Remise globale:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={globalDiscount}
|
||||
onChange={(e) => setGlobalDiscount(Number(e.target.value))}
|
||||
className="w-20 h-8 text-right"
|
||||
/>
|
||||
<span className="text-sm">%</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-red-500">
|
||||
-{globalDiscountAmount.toFixed(2)} €
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total TTC:</span>
|
||||
<span className="text-primary">{finalTTC.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
Paiement
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode de paiement</Label>
|
||||
<Select
|
||||
value={currentPayment.mode}
|
||||
onValueChange={(value: any) =>
|
||||
setCurrentPayment({ ...currentPayment, mode: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ESPECES">Espèces</SelectItem>
|
||||
<SelectItem value="CARTE">Carte bancaire</SelectItem>
|
||||
<SelectItem value="CHEQUE">Chèque</SelectItem>
|
||||
<SelectItem value="VIREMENT">Virement</SelectItem>
|
||||
<SelectItem value="BON_CAISSE">Bon de caisse</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Montant</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={currentPayment.montant || ''}
|
||||
onChange={(e) => setCurrentPayment({
|
||||
...currentPayment,
|
||||
montant: Number(e.target.value)
|
||||
})}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Button onClick={addPayment} disabled={cart.length === 0}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments list */}
|
||||
{payments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Paiements enregistrés</Label>
|
||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||
{payments.map((payment, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{payment.mode === 'ESPECES' && <DollarSign className="h-4 w-4 text-green-500" />}
|
||||
{payment.mode === 'CARTE' && <CreditCard className="h-4 w-4 text-blue-500" />}
|
||||
{payment.mode === 'CHEQUE' && <FileText className="h-4 w-4 text-purple-500" />}
|
||||
{payment.mode === 'VIREMENT' && <FileText className="h-4 w-4 text-orange-500" />}
|
||||
{payment.mode === 'BON_CAISSE' && <FileText className="h-4 w-4 text-cyan-500" />}
|
||||
<span className="text-sm capitalize">
|
||||
{payment.mode.toLowerCase().replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{payment.montant.toFixed(2)} €</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => removePayment(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<span>Reste à payer:</span>
|
||||
<span className={`font-bold ${remainingToPay <= 0.01 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{Math.max(0, remainingToPay).toFixed(2)} €
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={completeSale}
|
||||
disabled={cart.length === 0 || remainingToPay > 0.01}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
Valider la vente
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Sales History */}
|
||||
<TabsContent value="history">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historique des ventes</CardTitle>
|
||||
<CardDescription>Les dernières ventes enregistrées</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>N° Vente</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Montant TTC</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{salesHistory.map((sale) => (
|
||||
<TableRow key={sale.id}>
|
||||
<TableCell className="font-medium">{sale.numero}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(sale.date).toLocaleDateString('fr-FR')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sale.client
|
||||
? `${sale.client.prenom} ${sale.client.nom}`
|
||||
: 'Client anonyme'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(sale.statut)}</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{sale.montantTTC.toFixed(2)} €
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{salesHistory.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
|
||||
Aucune vente enregistrée
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Invoice Dialog */}
|
||||
<Dialog open={showInvoiceDialog} onOpenChange={setShowInvoiceDialog}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Facture {currentSale?.numero}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vente complétée avec succès
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{currentSale && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold mb-1">OptiqueStock</p>
|
||||
<p className="text-gray-600">Système de Gestion d'Optique</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">Facture N° {currentSale.numero}</p>
|
||||
<p className="text-gray-600">
|
||||
{new Date(currentSale.date).toLocaleDateString('fr-FR')} à{' '}
|
||||
{new Date(currentSale.date).toLocaleTimeString('fr-FR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentSale.client && (
|
||||
<div className="bg-gray-50 p-4 rounded">
|
||||
<p className="font-semibold mb-1">Client</p>
|
||||
<p>{currentSale.client.prenom} {currentSale.client.nom}</p>
|
||||
<p className="text-sm text-gray-600">{currentSale.client.telephone}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Total HT:</span>
|
||||
<span>{currentSale.montantHT.toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>TVA:</span>
|
||||
<span>{currentSale.montantTVA.toFixed(2)} €</span>
|
||||
</div>
|
||||
{currentSale.remise > 0 && (
|
||||
<div className="flex justify-between text-red-600">
|
||||
<span>Remise:</span>
|
||||
<span>-{currentSale.remise.toFixed(2)} €</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total TTC:</span>
|
||||
<span>{currentSale.montantTTC.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
{getStatusBadge(currentSale.statut)}
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setShowInvoiceDialog(false)} className="w-full">
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user