add payement detail page
This commit is contained in:
parent
ac06585c0e
commit
0b8c217a91
4 changed files with 614 additions and 516 deletions
|
|
@ -1,514 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
ArrowLeft, Lock, Download, MoreVertical, Trash2,
|
|
||||||
CheckCircle, Plus, RefreshCw, Eye
|
|
||||||
} from 'lucide-react';
|
|
||||||
import StatusBadge from '@/components/StatusBadge';
|
|
||||||
import PrimaryButton from '@/components/PrimaryButton';
|
|
||||||
import Tabs from '@/components/Tabs';
|
|
||||||
import Timeline from '@/components/Timeline';
|
|
||||||
import { Input, Select, Textarea, FormField } from '@/components/FormModal';
|
|
||||||
import ClientSearchInput from '@/components/inputs/ClientSearchInput';
|
|
||||||
import PDFPreviewPanel from '@/components/panels/PDFPreviewPanel';
|
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { mockPayments, mockInvoices, mockClients, currentUser } from '@/data/mockData';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const formatCurrency = (val) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(val);
|
|
||||||
|
|
||||||
const emptyPayment = {
|
|
||||||
id: 'new',
|
|
||||||
number: 'REG-PROVISOIRE',
|
|
||||||
clientId: null,
|
|
||||||
clientName: '',
|
|
||||||
date: new Date().toISOString().split('T')[0],
|
|
||||||
mode: 'Virement',
|
|
||||||
reference: '',
|
|
||||||
amount: 0,
|
|
||||||
allocatedAmount: 0,
|
|
||||||
remainingBalance: 0,
|
|
||||||
status: 'Brouillon',
|
|
||||||
notes: '',
|
|
||||||
allocations: [],
|
|
||||||
timeline: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const PaymentDetailPage = () => {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const isNew = id === 'new';
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('identification');
|
|
||||||
const [isEditing, setIsEditing] = useState(isNew);
|
|
||||||
const [isPDFPreviewOpen, setIsPDFPreviewOpen] = useState(false);
|
|
||||||
|
|
||||||
const [payment, setPayment] = useState(null);
|
|
||||||
const [selectedClient, setSelectedClient] = useState(null);
|
|
||||||
const [clientInvoices, setClientInvoices] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isNew) {
|
|
||||||
// Check for initial client from state (e.g., coming from Client Detail)
|
|
||||||
const initialClient = location.state?.client || null;
|
|
||||||
const initialInvoice = location.state?.invoice || null;
|
|
||||||
|
|
||||||
let newPayment = { ...emptyPayment };
|
|
||||||
if (initialClient) {
|
|
||||||
newPayment.clientId = initialClient.id;
|
|
||||||
newPayment.clientName = initialClient.company;
|
|
||||||
setSelectedClient(initialClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialInvoice) {
|
|
||||||
// Pre-fill amount from invoice balance
|
|
||||||
newPayment.amount = initialInvoice.balanceDue;
|
|
||||||
newPayment.reference = `Regl. ${initialInvoice.number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPayment(newPayment);
|
|
||||||
setIsEditing(true);
|
|
||||||
setActiveTab('identification');
|
|
||||||
} else {
|
|
||||||
const found = mockPayments.find(p => p.id === parseInt(id));
|
|
||||||
if (found) {
|
|
||||||
setPayment({
|
|
||||||
...found,
|
|
||||||
// Ensure fields exist
|
|
||||||
notes: found.notes || '',
|
|
||||||
timeline: found.timeline || []
|
|
||||||
});
|
|
||||||
const fullClient = mockClients.find(c => c.id === found.clientId);
|
|
||||||
setSelectedClient(fullClient || { id: found.clientId, company: found.clientName });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [id, isNew, location.state]);
|
|
||||||
|
|
||||||
// Load Invoices when client changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (payment?.clientId) {
|
|
||||||
// Find unpaid invoices for this client
|
|
||||||
const openInvoices = mockInvoices.filter(inv =>
|
|
||||||
inv.clientId === payment.clientId &&
|
|
||||||
inv.status !== 'Brouillon' &&
|
|
||||||
inv.balanceDue > 0
|
|
||||||
).sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)); // Sort by Due Date Ascending
|
|
||||||
|
|
||||||
// Augment invoices with allocation data from current payment
|
|
||||||
const invoicesWithAllocation = openInvoices.map(inv => {
|
|
||||||
const existingAlloc = payment.allocations.find(a => a.invoiceId === inv.id);
|
|
||||||
return {
|
|
||||||
...inv,
|
|
||||||
currentAllocation: existingAlloc ? existingAlloc.amount : 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setClientInvoices(invoicesWithAllocation);
|
|
||||||
} else {
|
|
||||||
setClientInvoices([]);
|
|
||||||
}
|
|
||||||
}, [payment?.clientId, payment?.allocations]); // Re-run if allocations change to keep sync, though simple state update below handles input
|
|
||||||
|
|
||||||
// Calculations
|
|
||||||
useEffect(() => {
|
|
||||||
if (!payment) return;
|
|
||||||
// Only recalc if editing
|
|
||||||
if (isEditing) {
|
|
||||||
const totalAllocated = payment.allocations.reduce((sum, a) => sum + a.amount, 0);
|
|
||||||
setPayment(prev => ({
|
|
||||||
...prev,
|
|
||||||
allocatedAmount: totalAllocated,
|
|
||||||
remainingBalance: Math.max(0, prev.amount - totalAllocated)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [payment?.allocations, payment?.amount, isEditing]);
|
|
||||||
|
|
||||||
if (!payment) return <div className="min-h-screen flex items-center justify-center"><div className="animate-spin w-8 h-8 border-4 border-[#941403] border-t-transparent rounded-full"></div></div>;
|
|
||||||
|
|
||||||
const isDraft = payment.status === 'Brouillon';
|
|
||||||
const isValidated = payment.status === 'Validé' || payment.status === 'Imputé' || payment.status === 'Crédit client';
|
|
||||||
const fieldsDisabled = !isEditing || isValidated;
|
|
||||||
|
|
||||||
// --- HANDLERS ---
|
|
||||||
const handleClientSelect = (client) => {
|
|
||||||
if (fieldsDisabled) return;
|
|
||||||
setSelectedClient(client);
|
|
||||||
setPayment(prev => ({
|
|
||||||
...prev,
|
|
||||||
clientName: client ? (client.company || `${client.firstName} ${client.lastName}`) : '',
|
|
||||||
clientId: client ? client.id : null,
|
|
||||||
allocations: [] // Clear allocations on client change
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAmountChange = (val) => {
|
|
||||||
const newAmount = parseFloat(val) || 0;
|
|
||||||
setPayment(prev => ({ ...prev, amount: newAmount }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAutoAllocate = () => {
|
|
||||||
let remaining = payment.amount;
|
|
||||||
const newAllocations = [];
|
|
||||||
|
|
||||||
const updatedInvoices = [...clientInvoices]; // These are sorted by due date
|
|
||||||
|
|
||||||
for (const inv of updatedInvoices) {
|
|
||||||
if (remaining <= 0) break;
|
|
||||||
|
|
||||||
const amountToAllocate = Math.min(remaining, inv.balanceDue);
|
|
||||||
if (amountToAllocate > 0) {
|
|
||||||
newAllocations.push({
|
|
||||||
invoiceId: inv.id,
|
|
||||||
invoiceNumber: inv.number,
|
|
||||||
amount: amountToAllocate
|
|
||||||
});
|
|
||||||
remaining -= amountToAllocate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPayment(prev => ({
|
|
||||||
...prev,
|
|
||||||
allocations: newAllocations
|
|
||||||
}));
|
|
||||||
|
|
||||||
toast({ title: "Imputation automatique", description: "Les montants ont été répartis par échéance." });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManualAllocation = (invoiceId, val) => {
|
|
||||||
const amount = parseFloat(val) || 0;
|
|
||||||
// Validate max amount
|
|
||||||
const invoice = clientInvoices.find(i => i.id === invoiceId);
|
|
||||||
if (!invoice) return;
|
|
||||||
|
|
||||||
if (amount > invoice.balanceDue) {
|
|
||||||
// Prevent over allocation on invoice
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check global limit? Maybe soft check or just visual
|
|
||||||
|
|
||||||
setPayment(prev => {
|
|
||||||
const filtered = prev.allocations.filter(a => a.invoiceId !== invoiceId);
|
|
||||||
if (amount > 0) {
|
|
||||||
filtered.push({
|
|
||||||
invoiceId: invoiceId,
|
|
||||||
invoiceNumber: invoice.number,
|
|
||||||
amount: amount
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ...prev, allocations: filtered };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!payment.clientId) {
|
|
||||||
toast({ title: "Client manquant", description: "Veuillez sélectionner un client.", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payment.amount <= 0) {
|
|
||||||
toast({ title: "Montant invalide", description: "Le montant doit être supérieur à 0.", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEditing(false);
|
|
||||||
if (isNew) {
|
|
||||||
toast({ title: "Règlement créé", description: "Le brouillon a été enregistré.", variant: "success" });
|
|
||||||
navigate('/reglements');
|
|
||||||
} else {
|
|
||||||
setPayment(prev => ({
|
|
||||||
...prev,
|
|
||||||
timeline: [{ date: new Date().toISOString(), type: 'update', title: 'Règlement modifié', user: currentUser.name }, ...prev.timeline]
|
|
||||||
}));
|
|
||||||
toast({ title: "Sauvegardé", description: "Les modifications ont été enregistrées.", variant: "success" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidate = () => {
|
|
||||||
let finalStatus = 'Validé';
|
|
||||||
if (payment.remainingBalance === 0 && payment.amount > 0) finalStatus = 'Imputé';
|
|
||||||
if (payment.remainingBalance > 0) finalStatus = 'Crédit client';
|
|
||||||
|
|
||||||
setPayment(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: finalStatus,
|
|
||||||
number: prev.number.replace('PROVISOIRE', `REG-2025-${Math.floor(Math.random()*1000)}`),
|
|
||||||
timeline: [{ date: new Date().toISOString(), type: 'validation', title: 'Règlement validé', description: `Statut: ${finalStatus}`, user: currentUser.name }, ...prev.timeline]
|
|
||||||
}));
|
|
||||||
setIsEditing(false);
|
|
||||||
toast({ title: "Règlement validé", description: "Le paiement est verrouillé et les écritures sont passées.", variant: "success" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'identification', label: 'Identification' },
|
|
||||||
{ id: 'allocations', label: 'Imputations', count: payment.allocations.length },
|
|
||||||
{ id: 'documents', label: 'Documents', count: payment.linkedDocuments?.length || 0 },
|
|
||||||
{ id: 'timeline', label: 'Historique' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>{payment.number} - Règlement - Bijou ERP</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<div className="flex flex-col min-h-screen bg-gray-50/50 dark:bg-black/20 pb-20">
|
|
||||||
|
|
||||||
{/* TOP HEADER */}
|
|
||||||
<div className="bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30 shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex items-center justify-between h-16">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button onClick={() => navigate('/reglements')} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500">
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{payment.number}</h1>
|
|
||||||
<StatusBadge status={payment.status} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-300">{payment.clientName || 'Client inconnu'}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{new Date(payment.date).toLocaleDateString()}</span>
|
|
||||||
<span className="text-[#941403] font-bold ml-2">{formatCurrency(payment.amount)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsPDFPreviewOpen(true)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">Aperçu</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isDraft && (
|
|
||||||
<PrimaryButton onClick={handleValidate} className="bg-blue-600 hover:bg-blue-700">
|
|
||||||
<Lock className="w-4 h-4 mr-2" /> Valider
|
|
||||||
</PrimaryButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDraft && !isNew && (
|
|
||||||
<button className="p-2 border border-gray-200 dark:border-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-900 text-gray-600 dark:text-gray-400" title="Télécharger">
|
|
||||||
<Download className="w-5 h-5"/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDraft && !isEditing && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditing && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => isNew ? navigate('/reglements') : setIsEditing(false)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<PrimaryButton onClick={handleSave}>Enregistrer</PrimaryButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* LOCKED BANNER */}
|
|
||||||
{isValidated && (
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 px-4 py-3 text-center">
|
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium flex items-center justify-center gap-2">
|
|
||||||
<Lock className="w-4 h-4" />
|
|
||||||
Ce règlement est validé et n'est plus modifiable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full mt-6">
|
|
||||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{/* --- IDENTIFICATION TAB --- */}
|
|
||||||
{activeTab === 'identification' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 relative overflow-hidden">
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Détails du paiement</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<FormField label="Client *" required fullWidth>
|
|
||||||
<ClientSearchInput
|
|
||||||
value={selectedClient}
|
|
||||||
onChange={handleClientSelect}
|
|
||||||
disabled={fieldsDisabled}
|
|
||||||
placeholder="Rechercher un client..."
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField label="Date de règlement" required>
|
|
||||||
<Input type="date" value={payment.date} onChange={(e) => !fieldsDisabled && setPayment({...payment, date: e.target.value})} disabled={fieldsDisabled} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Mode de paiement">
|
|
||||||
<Select value={payment.mode} onChange={(e) => !fieldsDisabled && setPayment({...payment, mode: e.target.value})} disabled={fieldsDisabled}>
|
|
||||||
<option value="Virement">Virement</option>
|
|
||||||
<option value="CB">Carte Bancaire</option>
|
|
||||||
<option value="Prélèvement">Prélèvement</option>
|
|
||||||
<option value="Chèque">Chèque</option>
|
|
||||||
<option value="Espèces">Espèces</option>
|
|
||||||
<option value="Autre">Autre</option>
|
|
||||||
</Select>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<FormField label="Référence bancaire / Libellé">
|
|
||||||
<Input value={payment.reference} onChange={(e) => !fieldsDisabled && setPayment({...payment, reference: e.target.value})} disabled={fieldsDisabled} placeholder="Ex: VIR 12345" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Montant reçu (€) *" required>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={payment.amount}
|
|
||||||
onChange={(e) => !fieldsDisabled && handleAmountChange(e.target.value)}
|
|
||||||
disabled={fieldsDisabled}
|
|
||||||
className="font-bold text-lg"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Notes internes" fullWidth>
|
|
||||||
<Textarea value={payment.notes} onChange={(e) => !fieldsDisabled && setPayment({...payment, notes: e.target.value})} disabled={fieldsDisabled} rows={2} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- ALLOCATIONS TAB --- */}
|
|
||||||
{activeTab === 'allocations' && (
|
|
||||||
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-sm">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Imputations</h3>
|
|
||||||
<p className="text-sm text-gray-500">Répartissez le montant reçu sur les factures en attente.</p>
|
|
||||||
</div>
|
|
||||||
{!fieldsDisabled && (
|
|
||||||
<button
|
|
||||||
onClick={handleAutoAllocate}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-700 hover:bg-blue-100 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" /> Imputation automatique
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Bar */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
||||||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800">
|
|
||||||
<p className="text-xs text-gray-500 uppercase font-bold">Montant Reçu</p>
|
|
||||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatCurrency(payment.amount)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-100 dark:border-green-800">
|
|
||||||
<p className="text-xs text-green-700 uppercase font-bold">Montant Imputé</p>
|
|
||||||
<p className="text-xl font-bold text-green-700">{formatCurrency(payment.allocatedAmount)}</p>
|
|
||||||
</div>
|
|
||||||
<div className={cn(
|
|
||||||
"p-4 rounded-xl border",
|
|
||||||
payment.remainingBalance > 0 ? "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800" : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800"
|
|
||||||
)}>
|
|
||||||
<p className={cn("text-xs uppercase font-bold", payment.remainingBalance > 0 ? "text-purple-700" : "text-gray-500")}>
|
|
||||||
{payment.remainingBalance > 0 ? "Solde (Crédit)" : "Solde"}
|
|
||||||
</p>
|
|
||||||
<p className={cn("text-xl font-bold", payment.remainingBalance > 0 ? "text-purple-700" : "text-gray-500")}>
|
|
||||||
{formatCurrency(payment.remainingBalance)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{clientInvoices.length > 0 ? (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900 text-xs uppercase text-gray-500 font-semibold border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left">Facture</th>
|
|
||||||
<th className="px-4 py-3 text-left">Date</th>
|
|
||||||
<th className="px-4 py-3 text-left text-red-600">Dû le</th>
|
|
||||||
<th className="px-4 py-3 text-right">Montant TTC</th>
|
|
||||||
<th className="px-4 py-3 text-right">Reste à payer</th>
|
|
||||||
<th className="px-4 py-3 text-right w-40">Imputation</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
||||||
{clientInvoices.map(inv => {
|
|
||||||
const allocatedHere = payment.allocations.find(a => a.invoiceId === inv.id)?.amount || 0;
|
|
||||||
return (
|
|
||||||
<tr key={inv.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
|
|
||||||
<td className="px-4 py-3 font-medium">{inv.number}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-500">{new Date(inv.date).toLocaleDateString()}</td>
|
|
||||||
<td className="px-4 py-3 text-red-600 font-medium">{new Date(inv.dueDate).toLocaleDateString()}</td>
|
|
||||||
<td className="px-4 py-3 text-right text-gray-600">{formatCurrency(inv.amountTTC)}</td>
|
|
||||||
<td className="px-4 py-3 text-right font-bold">{formatCurrency(inv.balanceDue)}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max={inv.balanceDue}
|
|
||||||
value={allocatedHere}
|
|
||||||
onChange={(e) => handleManualAllocation(inv.id, e.target.value)}
|
|
||||||
disabled={fieldsDisabled}
|
|
||||||
className={cn(
|
|
||||||
"text-right h-8 py-1",
|
|
||||||
allocatedHere > 0 && "border-green-500 bg-green-50 text-green-900 font-bold"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<CheckCircle className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
|
||||||
<p>Aucune facture en attente de paiement pour ce client.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- DOCUMENTS TAB --- */}
|
|
||||||
{activeTab === 'documents' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Reuse simplified doc view */}
|
|
||||||
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-8 text-center text-gray-500">
|
|
||||||
<p>Module Documents identique aux autres pages...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* --- TIMELINE TAB --- */}
|
|
||||||
{activeTab === 'timeline' && (
|
|
||||||
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
|
|
||||||
<Timeline events={payment.timeline} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PDFPreviewPanel
|
|
||||||
isOpen={isPDFPreviewOpen}
|
|
||||||
onClose={() => setIsPDFPreviewOpen(false)}
|
|
||||||
title={`Recu - ${payment.number}`}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PaymentDetailPage;
|
|
||||||
607
src/pages/sales/PaymentDetailPage.tsx
Normal file
607
src/pages/sales/PaymentDetailPage.tsx
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
Download,
|
||||||
|
Euro,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
AlertTriangle,
|
||||||
|
Building,
|
||||||
|
Receipt,
|
||||||
|
Wallet,
|
||||||
|
User,
|
||||||
|
MapPin,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import { cn, formatDateFR, formatDateFRCourt } from "@/lib/utils";
|
||||||
|
import { ModalLoading } from "@/components/modal/ModalLoading";
|
||||||
|
import Tabs from "@/components/Tabs";
|
||||||
|
import { getAllReglements, getreglementSelected, reglementStatus } from "@/store/features/reglement/selectors";
|
||||||
|
import { loadAllReglementData } from "@/store/features/reglement/thunk";
|
||||||
|
import { FacturesReglement, echeances, ReglementDetail } from "@/types/reglementType";
|
||||||
|
import { selectClient } from "@/store/features/client/slice";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONSTANTES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const STATUT_REGLEMENT = {
|
||||||
|
SOLDE: 'Soldé',
|
||||||
|
PARTIEL: 'Partiellement réglé',
|
||||||
|
NON_REGLE: 'Non réglé',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANTS UTILITAIRES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ReglementStatusBadge = ({ status }: { status: string }) => {
|
||||||
|
const config: Record<string, { label: string; className: string; icon: React.ElementType }> = {
|
||||||
|
[STATUT_REGLEMENT.SOLDE]: {
|
||||||
|
label: 'Payé',
|
||||||
|
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
icon: CheckCircle
|
||||||
|
},
|
||||||
|
[STATUT_REGLEMENT.PARTIEL]: {
|
||||||
|
label: 'Partiel',
|
||||||
|
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
icon: Clock
|
||||||
|
},
|
||||||
|
[STATUT_REGLEMENT.NON_REGLE]: {
|
||||||
|
label: 'Non réglé',
|
||||||
|
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
icon: AlertTriangle
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = config[status] || config[STATUT_REGLEMENT.NON_REGLE];
|
||||||
|
const Icon = statusConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium",
|
||||||
|
statusConfig.className
|
||||||
|
)}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfoCard = ({ icon: Icon, label, value, className }: { icon: React.ElementType; label: string; value: string | number; className?: string }) => (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
|
<Icon className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{label}</p>
|
||||||
|
<p className={cn("text-sm font-medium text-gray-900 dark:text-white", className)}>{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const PaymentDetailPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
|
||||||
|
const reglements = useAppSelector(getAllReglements) as FacturesReglement[];
|
||||||
|
const status = useAppSelector(reglementStatus);
|
||||||
|
|
||||||
|
const isLoading = status === 'loading' && reglements.length === 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
if (status === 'idle' || status === 'failed') {
|
||||||
|
await dispatch(loadAllReglementData()).unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [status, dispatch]);
|
||||||
|
|
||||||
|
// Trouver le règlement correspondant
|
||||||
|
const reglement = useAppSelector(getreglementSelected)
|
||||||
|
|
||||||
|
// Calculs
|
||||||
|
const totalEcheances = useMemo(() => {
|
||||||
|
return reglement?.echeances?.length || 0;
|
||||||
|
}, [reglement]);
|
||||||
|
|
||||||
|
const totalReglements = useMemo(() => {
|
||||||
|
return reglement?.echeances?.reduce((total, ech) => total + (ech.reglements?.length || 0), 0) || 0;
|
||||||
|
}, [reglement]);
|
||||||
|
|
||||||
|
const echeancesReglees = useMemo(() => {
|
||||||
|
return reglement?.echeances?.filter(ech => ech.est_regle).length || 0;
|
||||||
|
}, [reglement]);
|
||||||
|
|
||||||
|
const prochainEcheance = useMemo(() => {
|
||||||
|
const echeancesNonReglees = reglement?.echeances?.filter(ech => !ech.est_regle) || [];
|
||||||
|
if (echeancesNonReglees.length === 0) return null;
|
||||||
|
return echeancesNonReglees.sort((a, b) => new Date(a.date_echeance).getTime() - new Date(b.date_echeance).getTime())[0];
|
||||||
|
}, [reglement]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ModalLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reglement) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-gray-400" />
|
||||||
|
<p className="text-gray-500">Règlement introuvable</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/home/reglements')}
|
||||||
|
className="text-[#007E45] hover:underline"
|
||||||
|
>
|
||||||
|
Retour à la liste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "overview", label: "Vue d'ensemble" },
|
||||||
|
{ id: "echeances", label: "Échéances", count: totalEcheances },
|
||||||
|
{ id: "reglements", label: "Règlements", count: totalReglements },
|
||||||
|
{ id: "client", label: "Client" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const client = reglement.client;
|
||||||
|
const firstReglement = reglement.echeances?.[0]?.reglements?.[0];
|
||||||
|
const reglementNo = firstReglement?.rg_no;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{reglement.numero} - Règlement</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="sticky top-0 z-30 bg-white border-b border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/home/reglements")}
|
||||||
|
title="Retour à la liste"
|
||||||
|
className="p-2 text-gray-500 rounded-full transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{reglementNo ? `REG_${reglementNo}` : '-'}
|
||||||
|
</h1>
|
||||||
|
<ReglementStatusBadge status={reglement.statut_reglement} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center text-sm text-gray-500">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-300">
|
||||||
|
{client?.intitule || 'Client inconnu'}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDateFR(reglement.date)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-gray-400">{reglement.type_libelle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/home/factures/${reglement.numero}`)}
|
||||||
|
className="flex gap-2 items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-xl border border-gray-200 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Voir la facture
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toast({ title: 'Téléchargement', description: 'Génération du PDF...' })}
|
||||||
|
className="flex gap-2 items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-xl border border-gray-200 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CONTENT */}
|
||||||
|
<div className="px-4 mx-auto mt-6 w-full max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* --- OVERVIEW TAB --- */}
|
||||||
|
{activeTab === "overview" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Résumé financier */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-5 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
|
<Euro className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Total TTC</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{formatCurrency(reglement.montants?.total_ttc || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Montant réglé</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{formatCurrency(reglement.montants?.montant_regle || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Reste à régler</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{formatCurrency(reglement.montants?.reste_a_regler || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<Receipt className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">Progression</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{reglement.montants?.total_ttc > 0
|
||||||
|
? Math.round((reglement.montants.montant_regle / reglement.montants.total_ttc) * 100)
|
||||||
|
: 0}%
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[#007E45] rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${reglement.montants?.total_ttc > 0
|
||||||
|
? (reglement.montants.montant_regle / reglement.montants.total_ttc) * 100
|
||||||
|
: 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informations générales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-gray-400" />
|
||||||
|
Informations du document
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoCard icon={Hash} label="Numéro" value={reglement.numero} />
|
||||||
|
<InfoCard icon={FileText} label="Type" value={reglement.type_libelle} />
|
||||||
|
<InfoCard icon={Calendar} label="Date" value={formatDateFRCourt(reglement.date)} />
|
||||||
|
<InfoCard icon={Calendar} label="Date livraison" value={reglement.date_livraison ? formatDateFRCourt(reglement.date_livraison) : '-'} />
|
||||||
|
<InfoCard icon={Hash} label="Référence" value={reglement.reference || '-'} />
|
||||||
|
<InfoCard icon={Calendar} label="Création" value={formatDateFRCourt(reglement.date_creation)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Wallet className="w-5 h-5 text-gray-400" />
|
||||||
|
Suivi des paiements
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InfoCard icon={Calendar} label="Échéances" value={`${echeancesReglees}/${totalEcheances} réglées`} />
|
||||||
|
<InfoCard icon={Receipt} label="Règlements" value={totalReglements} />
|
||||||
|
{prochainEcheance && (
|
||||||
|
<>
|
||||||
|
<InfoCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Prochaine échéance"
|
||||||
|
value={formatDateFRCourt(prochainEcheance.date_echeance)}
|
||||||
|
className="text-amber-600"
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
icon={Euro}
|
||||||
|
label="Montant dû"
|
||||||
|
value={formatCurrency(prochainEcheance.reste_a_regler)}
|
||||||
|
className="text-red-600"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Derniers règlements */}
|
||||||
|
{totalReglements > 0 && (
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<CreditCard className="w-5 h-5 text-gray-400" />
|
||||||
|
Derniers règlements
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reglement.echeances?.flatMap(ech => ech.reglements || []).slice(0, 3).map((reg, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
REG_{reg.rg_no}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formatDateFRCourt(reg.date)} • {reg.mode_reglement?.libelle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-green-600 dark:text-green-400">
|
||||||
|
+{formatCurrency(reg.montant)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{reg.reference || reg.libelle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- ECHEANCES TAB --- */}
|
||||||
|
{activeTab === "echeances" && (
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="text-xs font-semibold tracking-wider text-left text-gray-500 uppercase bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 rounded-l-lg">N° Échéance</th>
|
||||||
|
<th className="px-4 py-3">Date échéance</th>
|
||||||
|
<th className="px-4 py-3 text-right">Montant</th>
|
||||||
|
<th className="px-4 py-3 text-right">Réglé</th>
|
||||||
|
<th className="px-4 py-3 text-right">Reste</th>
|
||||||
|
<th className="px-4 py-3">Mode</th>
|
||||||
|
<th className="px-4 py-3 text-center">Statut</th>
|
||||||
|
<th className="px-4 py-3 rounded-r-lg text-center">Règlements</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{reglement.echeances && reglement.echeances.length > 0 ? (
|
||||||
|
reglement.echeances.map((ech, index) => (
|
||||||
|
<tr key={ech.dr_no || index} className="hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="font-mono font-bold text-gray-900 dark:text-white">
|
||||||
|
ECH_{ech.dr_no}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<span>{formatDateFRCourt(ech.date_echeance)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right font-medium">
|
||||||
|
{formatCurrency(ech.montant)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right font-medium text-green-600 dark:text-green-400">
|
||||||
|
{formatCurrency(ech.montant_regle)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right font-bold text-red-600 dark:text-red-400">
|
||||||
|
{formatCurrency(ech.reste_a_regler)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{ech.mode_reglement?.libelle || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-center">
|
||||||
|
{ech.est_regle ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Réglé
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
En attente
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-center">
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 text-xs font-bold bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||||
|
{ech.nb_reglements || 0}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-12 text-center text-gray-500">
|
||||||
|
<Calendar className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
Aucune échéance
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- REGLEMENTS TAB --- */}
|
||||||
|
{activeTab === "reglements" && (
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="text-xs font-semibold tracking-wider text-left text-gray-500 uppercase bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 rounded-l-lg">N° Règlement</th>
|
||||||
|
<th className="px-4 py-3">Date</th>
|
||||||
|
<th className="px-4 py-3">Référence</th>
|
||||||
|
<th className="px-4 py-3">Mode</th>
|
||||||
|
<th className="px-4 py-3">Journal</th>
|
||||||
|
<th className="px-4 py-3 text-right">Montant</th>
|
||||||
|
<th className="px-4 py-3 rounded-r-lg text-center">Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{reglement.echeances?.flatMap(ech => ech.reglements || []).length > 0 ? (
|
||||||
|
reglement.echeances?.flatMap(ech => ech.reglements || []).map((reg, index) => (
|
||||||
|
<tr key={reg.rg_no || index} className="hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="font-mono font-bold text-gray-900 dark:text-white">
|
||||||
|
REG_{reg.rg_no}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-gray-500">{reg.numero_piece}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{formatDateFRCourt(reg.date)}</span>
|
||||||
|
<span className="text-xs text-gray-500">{reg.heure}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className="text-sm">{reg.reference || '-'}</span>
|
||||||
|
<div className="text-xs text-gray-500">{reg.libelle}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm">{reg.mode_reglement?.libelle}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building className="w-4 h-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{reg.journal?.code}</span>
|
||||||
|
<div className="text-xs text-gray-500">{reg.journal?.intitule}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-right">
|
||||||
|
<span className="font-bold text-green-600 dark:text-green-400">
|
||||||
|
+{formatCurrency(reg.montant)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-center">
|
||||||
|
{reg.est_impaye ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded-full">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Impayé
|
||||||
|
</span>
|
||||||
|
) : reg.est_valide ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Validé
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400 rounded-full">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
En cours
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-gray-500">
|
||||||
|
<Receipt className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
Aucun règlement enregistré
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- CLIENT TAB --- */}
|
||||||
|
{activeTab === "client" && client && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-[#007E45]/10 flex items-center justify-center text-2xl font-bold text-[#007E45]">
|
||||||
|
{client.intitule?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{client.intitule}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">{client.numero}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InfoCard icon={User} label="Code client" value={client.numero} />
|
||||||
|
{client.email && <InfoCard icon={Mail} label="Email" value={client.email} />}
|
||||||
|
{client.telephone && <InfoCard icon={Phone} label="Téléphone" value={client.telephone} />}
|
||||||
|
</div>
|
||||||
|
{/* <button
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(selectClient(client as any));
|
||||||
|
navigate(`/home/clients/${client.numero}`);
|
||||||
|
}}
|
||||||
|
className="mt-6 w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-[#007E45] bg-[#007E45]/10 rounded-xl hover:bg-[#007E45]/20 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Voir la fiche client
|
||||||
|
</button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
|
||||||
|
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-gray-400" />
|
||||||
|
Adresse
|
||||||
|
</h3>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||||
|
<p className="text-gray-900 dark:text-white">{client.adresse || '-'}</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{client.code_postal} {client.ville}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentDetailPage;
|
||||||
|
|
@ -20,6 +20,7 @@ import { getAllReglements, getReglementStats, reglementStatus } from '@/store/fe
|
||||||
import { loadAllReglementData } from '@/store/features/reglement/thunk';
|
import { loadAllReglementData } from '@/store/features/reglement/thunk';
|
||||||
import ModalPaymentPanel from '@/components/modal/ModalPaymentPanel';
|
import ModalPaymentPanel from '@/components/modal/ModalPaymentPanel';
|
||||||
import { FacturesReglement, Statistique } from '@/types/reglementType';
|
import { FacturesReglement, Statistique } from '@/types/reglementType';
|
||||||
|
import { selectReglement } from '@/store/features/reglement/slice';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// CONSTANTES - STATUTS DE RÈGLEMENT
|
// CONSTANTES - STATUTS DE RÈGLEMENT
|
||||||
|
|
@ -214,6 +215,7 @@ const PaymentsPage = () => {
|
||||||
|
|
||||||
const isLoading = statusReglement === 'loading' && reglements.length === 0;
|
const isLoading = statusReglement === 'loading' && reglements.length === 0;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -556,8 +558,10 @@ const PaymentsPage = () => {
|
||||||
// selectedIds={selectedInvoiceIds}
|
// selectedIds={selectedInvoiceIds}
|
||||||
// onSelectRow={handleSelectInvoice}
|
// onSelectRow={handleSelectInvoice}
|
||||||
// onSelectAll={handleSelectAll}
|
// onSelectAll={handleSelectAll}
|
||||||
onRowClick={(row: FacturesReglement) => {
|
onRowClick={async(row: FacturesReglement) => {
|
||||||
console.log("Row clicked:", row.numero);
|
console.log("Row clicked:", row.numero);
|
||||||
|
dispatch(selectReglement(row))
|
||||||
|
navigate(`/home/reglements/${row.numero}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ import QuoteCreatePage from '@/pages/sales/QuoteCreate';
|
||||||
import InvoiceCreatePage from '@/pages/sales/InvoiceCreatePage';
|
import InvoiceCreatePage from '@/pages/sales/InvoiceCreatePage';
|
||||||
import SageBuilderPage from '@/pages/SageBuilderPage';
|
import SageBuilderPage from '@/pages/SageBuilderPage';
|
||||||
import PaymentsPage from '@/pages/sales/PaymentsPage';
|
import PaymentsPage from '@/pages/sales/PaymentsPage';
|
||||||
|
import PaymentDetailPage from '@/pages/sales/PaymentDetailPage';
|
||||||
|
|
||||||
const DatavenRoute = () => {
|
const DatavenRoute = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -99,7 +100,7 @@ const DatavenRoute = () => {
|
||||||
<Route path="/commercial" element={<CommercialPage />} />
|
<Route path="/commercial" element={<CommercialPage />} />
|
||||||
|
|
||||||
<Route path="/reglements" element={<PaymentsPage />} />
|
<Route path="/reglements" element={<PaymentsPage />} />
|
||||||
{/* <Route path="/reglements/:id" element={<PaymentDetailPage />} /> */}
|
<Route path="/reglements/:id" element={<PaymentDetailPage />} />
|
||||||
|
|
||||||
{/* Tiers - Articles & Familles */}
|
{/* Tiers - Articles & Familles */}
|
||||||
<Route path="/articles" element={<ArticlesPage />} />
|
<Route path="/articles" element={<ArticlesPage />} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue