Sage100/src/pages/sales/InvoicesPage.tsx
2026-01-20 11:06:26 +03:00

954 lines
No EOL
36 KiB
TypeScript

import { useState, useMemo, useEffect, useCallback } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Download, CheckCircle, FileText, Euro, AlertTriangle, X, Wallet, Info } from 'lucide-react';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { toast } from '@/components/ui/use-toast';
import { cn, formatDateFRCourt } from '@/lib/utils';
import { Facture, FactureAction } from '@/types/factureType';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { factureStatus, getAllfacture, getfactureSelected } from '@/store/features/factures/selectors';
import { getFactures, selectFactureAsync, validerFacture, getFacture } from '@/store/features/factures/thunk';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { ModalFacture } from '@/components/modal/ModalFacture';
import { DropdownMenuTable } from '@/components/DropdownMenu';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { articleStatus } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { getClients } from '@/store/features/client/thunk';
import { Client } from '@/types/clientType';
import ModalStatus from '@/components/modal/ModalStatus';
import StatusBadge, { STATUS_LABELS } from '@/components/ui/StatusBadge';
import { SageDocumentType } from '@/types/sageTypes';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import { CompanyInfo } from '@/data/mockData';
import PDFPreview, { DocumentData } from '@/components/modal/PDFPreview';
import { usePDFPreview } from '@/components/ui/PDFActionButtons';
import ExportDropdown from '@/components/common/ExportDropdown';
import { useDashboardData } from '@/store/hooks/useAppData';
import DataTable from '@/components/ui/DataTable';
import { getAllReglements, reglementStatus } from '@/store/features/reglement/selectors';
import { loadAllReglementData } from '@/store/features/reglement/thunk';
import ModalPaymentPanel from '@/components/modal/ModalPaymentPanel';
import { FacturesReglement } from '@/types/reglementType';
import { selectfacture } from '@/store/features/factures/slice';
import FormModal from '@/components/ui/FormModal';
import ModalValidationWarningCard from '@/components/modal/ModalValidationWarningCard';
import { ModalLoading } from '@/components/modal/ModalLoading';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
// ============================================
// CONSTANTES
// ============================================
const STATUT_REGLEMENT = {
SOLDE: 'Soldé',
PARTIEL: 'Partiellement réglé',
NON_REGLE: 'Non réglé',
} as const;
const COLORS = {
gray: 'bg-gray-400',
yellow: 'bg-yellow-400',
orange: 'bg-orange-400',
green: 'bg-green-400',
purple: 'bg-purple-400',
blue: 'bg-blue-400',
};
// Labels de statut pour les factures
const FACTURE_STATUS_LABELS = {
0: { label: 'Saisi', color: COLORS.gray },
1: { label: 'Confirmé', color: COLORS.yellow },
2: { label: 'A comptabiliser', color: COLORS.orange },
3: { label: 'Comptabilisé', color: COLORS.green },
// 4: { label: 'Payé', color: COLORS.purple },
5: { label: 'Validé', color: COLORS.blue },
6: { label: 'Payé', color: COLORS.green },
7: { label: 'Partiellement payé', color: COLORS.orange },
} as const;
export type StatusCode = keyof typeof FACTURE_STATUS_LABELS;
// ============================================
// TYPES
// ============================================
type FilterType = 'all' | 'paid' | 'pending' | 'validated';
interface FactureWithReglement extends Facture {
statut_reglement: string;
montant_regle: number;
reste_a_regler: number;
statut_display: number; // Statut à afficher (peut être 6 ou 7 selon règlement)
}
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (factures: Facture[]) => number | string;
getSubtitle: (factures: Facture[], value: number | string) => string;
getChange: (factures: Facture[], value: number | string, period: PeriodType, allFactures: Facture[]) => string;
getTrend: (factures: Facture[], value: number | string, period: PeriodType, allFactures: Facture[]) => 'up' | 'down' | 'neutral';
filter: (factures: Facture[]) => Facture[];
tooltip?: { content: string; source: string };
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'N° Pièce', visible: true, locked: true },
{ key: 'client_code', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ht_calcule', label: 'Montant HT', visible: false },
{ key: 'total_ttc_calcule', label: 'Montant TTC', visible: true },
{ key: 'reste_a_regler', label: 'Solde dû', visible: true },
{ key: 'statut', label: 'Statut', visible: true },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total HT',
icon: Euro,
color: 'blue',
getValue: (factures) => Math.round(factures.reduce((sum, item) => sum + item.total_ht, 0)),
getSubtitle: (factures) => {
const totalTTC = factures.reduce((sum, item) => sum + item.total_ttc, 0);
return `${totalTTC.toLocaleString('fr-FR')}€ TTC`;
},
getChange: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousTotalHT = previousPeriodFactures.reduce((sum, item) => sum + item.total_ht, 0);
return previousTotalHT > 0 ? (((Number(value) - previousTotalHT) / previousTotalHT) * 100).toFixed(1) : '0';
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousTotalHT = previousPeriodFactures.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) >= previousTotalHT ? 'up' : 'down';
},
filter: (factures) => factures,
tooltip: { content: "Total des factures sur la période.", source: "Ventes > Factures" }
},
{
id: 'pending',
title: 'Nombre de Factures',
icon: FileText,
color: 'orange',
getValue: (factures) => factures.length,
getSubtitle: (factures) => {
const paid = factures.filter(f => f.statut === 4);
const paymentRate = factures.length > 0 ? ((paid.length / factures.length) * 100).toFixed(1) : '0';
return `${paymentRate}% payées`;
},
getChange: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const countChange = Number(value) - previousPeriodFactures.length;
return countChange !== 0 ? `${countChange > 0 ? '+' : ''}${countChange}` : '0';
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
return Number(value) >= previousPeriodFactures.length ? 'up' : 'down';
},
filter: (factures) => factures.filter(f => f.statut === 1),
},
{
id: 'paid',
title: 'Montant Payé',
icon: CheckCircle,
color: 'green',
getValue: (factures) => {
const paid = factures.filter(f => f.statut === 4);
return Math.round(paid.reduce((sum, item) => sum + item.total_ht, 0));
},
getSubtitle: (factures) => {
const paid = factures.filter(f => f.statut === 4);
return `${paid.length} facture${paid.length > 1 ? 's' : ''}`;
},
getChange: (factures) => {
const paid = factures.filter(f => f.statut === 4);
return `${paid.length}/${factures.length}`;
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousPaid = previousPeriodFactures.filter(f => f.statut === 4);
const previousPaidAmount = previousPaid.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) >= previousPaidAmount ? 'up' : 'down';
},
filter: (factures) => factures.filter(f => f.statut === 4),
tooltip: { content: "Montant des factures payées sur la période.", source: "Ventes > Factures" }
},
{
id: 'validated',
title: 'Factures Validées',
icon: CheckCircle,
color: 'purple',
getValue: (factures) => factures.filter(f => f.valide === 1).length,
getSubtitle: (factures) => {
const pending = factures.filter(f => f.statut === 1);
return `${pending.length} en attente`;
},
getChange: (factures) => {
const validated = factures.filter(f => f.valide === 1);
return `${validated.length}/${factures.length}`;
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousValidated = previousPeriodFactures.filter(f => f.valide === 1);
return Number(value) >= previousValidated.length ? 'up' : 'down';
},
filter: (factures) => factures.filter(f => f.valide === 1),
},
];
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const InvoicesPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const [selectedInvoiceIds, setSelectedInvoiceIds] = useState<string[]>([]);
const [isPaymentPanelOpen, setIsPaymentPanelOpen] = useState(false);
const [isValidationDialogOpen, setIsValidationDialogOpen] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [facturesToValidate, setFacturesToValidate] = useState<FactureWithReglement[]>([]);
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
const { showPreview, openPreview, closePreview } = usePDFPreview();
const clients = useAppSelector(getAllClients) as Client[];
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const factures = useAppSelector(getAllfacture) as Facture[];
const facturesSelected = useAppSelector(getfactureSelected) as Facture;
const statusFacture = useAppSelector(factureStatus);
const reglements = useAppSelector(getAllReglements) as FacturesReglement[];
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [openStatus, setOpenStatus] = useState(false);
const [editing, setEditing] = useState<Facture | null>(null);
const isLoading = statusFacture === 'loading' && factures.length === 0;
const statusClient = useAppSelector(clientStatus);
const statusArticle = useAppSelector(articleStatus);
const statusReglement = useAppSelector(reglementStatus);
const statusCommercial = useAppSelector(commercialsStatus);
useEffect(() => {
const load = async () => {
try {
if (statusArticle === 'idle') await dispatch(getArticles()).unwrap();
if (statusClient === 'idle' || statusClient === 'failed') await dispatch(getClients()).unwrap();
if (statusFacture === 'idle' || statusFacture === 'failed') await dispatch(getFactures()).unwrap();
if (statusReglement === 'idle' || statusReglement === 'failed') await dispatch(loadAllReglementData()).unwrap();
if (statusCommercial === 'idle') await dispatch(getCommercials()).unwrap();
} catch (error) {
console.error(error);
}
};
load();
}, [statusArticle, statusClient, statusFacture, statusReglement, statusCommercial, dispatch]);
const { refresh } = useDashboardData();
// ============================================
// OPTIONS POUR LES FILTRES
// ============================================
const commercialOptions = useMemo(() => {
return commercials.map(c => ({
value: c.numero.toString(),
label: `${c.prenom || ''} ${c.nom || ''}`.trim() || `Commercial ${c.numero}`,
}));
}, [commercials]);
// Map pour retrouver le commercial d'un client rapidement
const clientCommercialMap = useMemo(() => {
const map = new Map<string, string>();
clients.forEach(client => {
if (client.commercial?.numero) {
map.set(client.numero, client.commercial.numero.toString());
}
});
return map;
}, [clients]);
const filterDefinitions = [
{
key: 'status',
label: 'Statut',
options: (Object.entries(FACTURE_STATUS_LABELS) as [string, typeof FACTURE_STATUS_LABELS[StatusCode]][]).map(
([value, { label, color }]) => ({
value: value.toString(),
label,
color,
})
),
},
{
key: 'rep',
label: 'Commercial',
options: commercialOptions,
},
];
// ============================================
// Map des règlements par numéro de facture
// ============================================
const reglementsMap = useMemo(() => {
return new Map(reglements.map(r => [r.numero, r]));
}, [reglements]);
// ============================================
// Factures enrichies avec statut règlement
// ============================================
const facturesWithReglement = useMemo((): FactureWithReglement[] => {
return factures.map(f => {
const reglement = reglementsMap.get(f.numero);
let resteARegler = f.total_ttc_calcule;
let montantRegle = 0;
let statutReglement: string = STATUT_REGLEMENT.NON_REGLE;
let statutDisplay = f.statut; // Par défaut, le statut original
if (reglement) {
statutReglement = reglement.statut_reglement || STATUT_REGLEMENT.NON_REGLE;
montantRegle = reglement.montants?.montant_regle || 0;
// Déterminer le statut_display selon le règlement
if (statutReglement === STATUT_REGLEMENT.SOLDE) {
resteARegler = 0;
statutDisplay = 6; // Payé (via règlement)
} else if (statutReglement === STATUT_REGLEMENT.PARTIEL) {
resteARegler = reglement.montants?.reste_a_regler || 0;
statutDisplay = 7; // Partiellement payé
} else {
resteARegler = reglement.montants?.reste_a_regler || f.total_ttc_calcule;
}
}
return {
...f,
statut_reglement: statutReglement,
montant_regle: montantRegle,
reste_a_regler: resteARegler,
statut_display: statutDisplay,
};
});
}, [factures, reglementsMap]);
// ============================================
// Client actuellement sélectionné
// ============================================
const selectedClientCode = useMemo(() => {
if (selectedInvoiceIds.length === 0) return null;
const firstSelectedFacture = facturesWithReglement.find(f => f.numero === selectedInvoiceIds[0]);
return firstSelectedFacture?.client_code || null;
}, [selectedInvoiceIds, facturesWithReglement]);
const selectedClient = useMemo(() => {
if (!selectedClientCode) return null;
return clients.find(c => c.numero === selectedClientCode);
}, [selectedClientCode, clients]);
// ============================================
// KPIs
// ============================================
const kpis = useMemo(() => {
const periodFilteredFactures = filterItemByPeriod(factures, period, 'date');
return KPI_CONFIG.map(config => {
const value = config.getValue(periodFilteredFactures);
return {
id: config.id,
title: config.title,
value: config.id === 'all' || config.id === 'paid' ? `${value.toLocaleString('fr-FR')}` : value,
change: config.getChange(periodFilteredFactures, value, period, factures),
trend: config.getTrend(periodFilteredFactures, value, period, factures),
icon: config.icon,
subtitle: config.getSubtitle(periodFilteredFactures, value),
color: config.color,
tooltip: config.tooltip,
isActive: activeFilter === config.id,
onClick: () => setActiveFilter(prev => (prev === config.id ? 'all' : config.id)),
};
});
}, [factures, period, activeFilter]);
// ============================================
// Filtrage combiné : Période + KPI + Filtres avancés
// ============================================
const filteredFactures = useMemo(() => {
// 1. Filtrer par période
let result: FactureWithReglement[] = filterItemByPeriod(facturesWithReglement, period, 'date');
// 2. Filtrer par KPI actif
const kpiConfig = KPI_CONFIG.find(k => k.id === activeFilter);
if (kpiConfig && activeFilter !== 'all') {
result = kpiConfig.filter(result) as FactureWithReglement[];
}
// 3. Filtres avancés (statut)
if (activeFilters.status && activeFilters.status.length > 0) {
result = result.filter(item =>
activeFilters.status!.includes(item.statut_display.toString())
);
}
// 4. Filtre par commercial
if (activeFilters.rep && activeFilters.rep.length > 0) {
result = result.filter(item => {
const commercialCode = clientCommercialMap.get(item.client_code);
return commercialCode && activeFilters.rep!.includes(commercialCode);
});
}
// 5. Tri par date décroissante
return [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [facturesWithReglement, period, activeFilter, activeFilters, clientCommercialMap]);
// ============================================
// Factures sélectionnables
// ============================================
const selectableFactures = useMemo(() => {
return filteredFactures.filter(f => {
const isNotSolde = f.statut_reglement !== STATUT_REGLEMENT.SOLDE;
const isSameClient = !selectedClientCode || f.client_code === selectedClientCode;
return isNotSolde && isSameClient;
});
}, [filteredFactures, selectedClientCode]);
// ============================================
// Factures sélectionnées (avec données de règlement)
// ============================================
const selectedFactures = useMemo(() => {
return facturesWithReglement.filter(f => selectedInvoiceIds.includes(f.numero));
}, [selectedInvoiceIds, facturesWithReglement]);
// ============================================
// Total sélectionné = somme des reste_a_regler
// ============================================
const totalSelectedBalance = useMemo(() => {
return selectedFactures.reduce((sum, f) => sum + f.reste_a_regler, 0);
}, [selectedFactures]);
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€`;
};
// ============================================
// Label du filtre actif
// ============================================
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
// ============================================
// Handlers
// ============================================
const handleSelectInvoice = (numero: string) => {
const facture = facturesWithReglement.find(f => f.numero === numero);
if (!facture) return;
if (facture.statut_reglement === STATUT_REGLEMENT.SOLDE) {
toast({
title: 'Facture déjà réglée',
description: 'Cette facture est déjà soldée.',
variant: 'destructive',
});
return;
}
if (selectedClientCode && facture.client_code !== selectedClientCode) {
toast({
title: 'Client différent',
description: 'Vous ne pouvez sélectionner que des factures du même client.',
variant: 'destructive',
});
return;
}
setSelectedInvoiceIds(prev => (prev.includes(numero) ? prev.filter(id => id !== numero) : [...prev, numero]));
};
const handleSelectAll = (checked: boolean) => {
if (checked && selectableFactures.length > 0) {
const firstClientCode = selectableFactures[0].client_code;
const sameClientFactures = selectableFactures.filter(f => f.client_code === firstClientCode);
setSelectedInvoiceIds(sameClientFactures.map(f => f.numero));
} else {
setSelectedInvoiceIds([]);
}
};
// ============================================
// Validation automatique des factures
// ============================================
const validateFacturesSequentially = useCallback(
async (facturesToValidate: FactureWithReglement[]) => {
setIsValidating(true);
try {
for (const facture of facturesToValidate) {
await dispatch(validerFacture(facture.numero)).unwrap();
const itemUpdated = (await dispatch(getFacture(facture.numero)).unwrap()) as Facture;
dispatch(selectfacture(itemUpdated));
}
toast({
title: 'Factures validées',
description: `${facturesToValidate.length} facture(s) validée(s) avec succès.`,
className: 'bg-green-500 text-white border-green-600',
});
await dispatch(getFactures()).unwrap();
return true;
} catch (err: any) {
toast({
title: 'Erreur de validation',
description: err.message || 'Impossible de valider les factures.',
variant: 'destructive',
});
return false;
} finally {
setIsValidating(false);
}
},
[dispatch]
);
const handlePayment = useCallback(() => {
if (selectedInvoiceIds.length === 0) return;
const nonValidatedFactures = selectedFactures.filter(f => f.valide !== 1);
if (nonValidatedFactures.length > 0) {
setFacturesToValidate(nonValidatedFactures);
setIsValidationDialogOpen(true);
} else {
setIsPaymentPanelOpen(true);
}
}, [selectedInvoiceIds, selectedFactures]);
const handleConfirmValidationAndPayment = useCallback(async () => {
setIsValidationDialogOpen(false);
setIsSaving(true);
const success = await validateFacturesSequentially(facturesToValidate);
if (success) {
setIsPaymentPanelOpen(true);
}
setIsSaving(false);
setFacturesToValidate([]);
}, [facturesToValidate, validateFacturesSequentially]);
const handleCancelValidation = useCallback(() => {
setIsValidationDialogOpen(false);
setFacturesToValidate([]);
}, []);
const handlePaymentSuccess = useCallback(() => {
setSelectedInvoiceIds([]);
setIsPaymentPanelOpen(false);
toast({
title: 'Règlement effectué',
description: 'Les factures ont été réglées avec succès.',
className: 'bg-green-500 text-white border-green-600',
});
dispatch(loadAllReglementData());
dispatch(getFactures());
}, [dispatch]);
// ============================================
// PDF Data
// ============================================
const pdfData = useMemo(() => {
if (!facturesSelected) return null;
return {
numero: facturesSelected.numero,
type: 'facture' as const,
date: facturesSelected.date,
client: {
code: facturesSelected.client_code,
nom: facturesSelected.client_intitule,
adresse: facturesSelected.client_adresse,
code_postal: facturesSelected.client_code_postal,
ville: facturesSelected.client_ville,
email: facturesSelected.client_email,
telephone: facturesSelected.client_telephone,
},
reference_externe: facturesSelected.reference,
lignes: (facturesSelected.lignes ?? []).map(l => ({
article: l.article_code,
designation: l.designation,
quantite: l.quantite,
prix_unitaire: l.prix_unitaire_ht ?? 0,
tva: 20,
total_ht: l.quantite * (l.prix_unitaire_ht ?? 0),
})),
total_ht: facturesSelected.total_ht_calcule,
total_tva: facturesSelected.total_taxes_calcule,
total_ttc: facturesSelected.total_ttc_calcule,
};
}, [facturesSelected]) as DocumentData;
const handleCreate = () => navigate('/home/factures/nouveau');
const handleEdit = (row: Facture) => {
setEditing(row);
setIsCreateModalOpen(true);
};
const handleAction = (action: FactureAction, row: Facture) => {
const messages: Record<FactureAction, { title: string; description: string }> = {
send: { title: 'Facture envoyée', description: `Email envoyé à ${row.client_intitule}` },
download: { title: 'Téléchargement', description: 'Génération du PDF en cours...' },
remind: { title: 'Relance envoyée', description: 'Le client a été notifié.' },
paid: { title: 'Facture payée', description: 'Statut mis à jour avec succès.' },
};
toast(messages[action]);
};
const openPDF = async (row: Facture) => {
await dispatch(selectFactureAsync(row)).unwrap();
openPreview();
};
// ============================================
// COLONNES
// ============================================
const allColumnsDefinition = useMemo(() => {
const clientsMap = new Map(clients.map(c => [c.numero, c]));
return {
numero: {
key: 'numero',
label: 'N° Pièce',
sortable: true,
render: (value: string) => <span className="font-bold">{value}</span>,
},
client_code: {
key: 'client_code',
label: 'Client',
sortable: true,
render: (clientCode: string) => {
const client = clientsMap.get(clientCode);
if (!client) return <span className="text-gray-400">{clientCode}</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.email || client.telephone || clientCode}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ht_calcule: {
key: 'total_ht_calcule',
label: 'Montant HT',
sortable: true,
render: (v: number) => `${v.toLocaleString()}`,
},
total_ttc_calcule: {
key: 'total_ttc_calcule',
label: 'Montant TTC',
sortable: true,
render: (v: number) => <span className="font-semibold">{formatCurrency(v)}</span>,
},
reste_a_regler: {
key: 'reste_a_regler',
label: 'Solde dû',
sortable: true,
render: (value: number, row: FactureWithReglement) => {
const isSolde = row.statut_reglement === STATUT_REGLEMENT.SOLDE;
const isPartiel = row.statut_reglement === STATUT_REGLEMENT.PARTIEL;
return (
<div className="flex flex-col">
<span
className={cn(
'font-bold',
isSolde
? 'text-green-600 dark:text-green-400'
: isPartiel
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
)}
>
{formatCurrency(value)}
</span>
{isPartiel && row.montant_regle > 0 && (
<span className="text-xs text-gray-500">(déjà réglé: {formatCurrency(row.montant_regle)})</span>
)}
</div>
);
},
},
statut: {
key: 'statut',
label: 'Statut',
sortable: true,
render: (v: number, row: FactureWithReglement) => (
<StatusBadge status={row.statut_display} type_doc={SageDocumentType.FACTURE} />
),
},
};
}, [clients]);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: value => (value ? formatDateFRCourt(value) : ''),
total_ht_calcule: value => formatCurrency(value || 0),
total_ttc_calcule: value => formatCurrency(value || 0),
reste_a_regler: value => formatCurrency(value || 0),
statut: (value, row) =>
row.statut_reglement || FACTURE_STATUS_LABELS[value as StatusCode]?.label || 'Inconnu',
client_intitule: (value, row) => value || row.client_code || '',
};
const actions = (row: FactureWithReglement) => {
const handleStatus =
row.statut !== 2 && row.statut !== 3 && row.statut !== 4
? async () => {
await dispatch(selectFactureAsync(row)).unwrap();
setOpenStatus(true);
}
: undefined;
return (
<>
<button
onClick={() => openPDF(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleAction('download', row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500"
title="Télécharger"
>
<Download className="w-4 h-4" />
</button>
<DropdownMenuTable
row={row}
onStatus={handleStatus}
onEdit={async () => {
const rep = (await dispatch(selectFactureAsync(row)).unwrap()) as Facture;
handleEdit(rep);
}}
onDownload={() => openPDF(row)}
/>
</>
);
};
// ============================================
// Conversion pour le modal de paiement
// ============================================
const selectedFacturesForModal = useMemo((): Facture[] => {
return selectedFactures.map(f => ({
...f,
total_ttc_calcule: f.reste_a_regler,
}));
}, [selectedFactures]);
return (
<>
<Helmet>
<title>Factures - Dataven</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusFacture} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Factures</h1>
{activeFilter !== 'all' && (
<button
onClick={() => setActiveFilter('all')}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-colors',
'bg-[#007E45]/10 text-[#007E45] hover:bg-[#007E45]/20'
)}
>
{activeFilterLabel}
<X className="w-3 h-3" />
</button>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{filteredFactures.length} facture{filteredFactures.length > 1 ? 's' : ''}
{activeFilter !== 'all' && ` (${activeFilterLabel.toLowerCase()})`}
</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredFactures}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="Facture"
/>
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({
...prev,
[key]: values,
}));
}}
onReset={() => setActiveFilters({})}
/>
{selectedInvoiceIds.length > 0 && (
<div className="flex items-center gap-2">
{selectedClient && (
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{selectedClient.intitule}
</span>
)}
<PrimaryButton_v2
onClick={handlePayment}
className="bg-[#338660] hover:bg-[#2A6F4F] shadow-lg shadow-green-900/20 animate-in fade-in zoom-in duration-200"
>
<Wallet className="w-4 h-4 mr-2" />
Régler ({selectedInvoiceIds.length}) · {formatCurrency(totalSelectedBalance)}
</PrimaryButton_v2>
</div>
)}
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouvelle facture
</PrimaryButton_v2>
</div>
</div>
{selectedClientCode && (
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-sm rounded-lg border border-blue-100 dark:border-blue-900/50">
<Info className="w-4 h-4 flex-shrink-0" />
<span>
Sélection limitée au client <strong>{selectedClient?.intitule}</strong>.
<button onClick={() => setSelectedInvoiceIds([])} className="ml-2 underline hover:no-underline">
Réinitialiser
</button>
</span>
</div>
)}
<DataTable
columns={visibleColumns}
data={filteredFactures}
actions={actions}
status={isLoading}
selectable={true}
selectableFactures={selectableFactures}
selectedIds={selectedInvoiceIds}
onSelectRow={handleSelectInvoice}
onSelectAll={handleSelectAll}
onRowClick={async (row: Facture) => {
await dispatch(selectFactureAsync(row)).unwrap();
navigate(`/home/factures/${row.numero}`);
}}
/>
</div>
<ModalStatus open={openStatus} onClose={() => setOpenStatus(false)} type_doc={SageDocumentType.FACTURE} />
<ModalFacture
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour la facture ${editing.numero}` : 'Créer une facture'}
editing={editing}
/>
<FormModal
isOpen={isValidationDialogOpen}
onClose={() => setIsValidationDialogOpen(false)}
title="Valider la facture"
>
<div className="p-6 space-y-4">
<ModalValidationWarningCard
icon={AlertTriangle}
title="Validation automatique"
description="En procédant au règlement, les factures sélectionnées seront automatiquement validées. Cette action est irréversible."
onClick={handleConfirmValidationAndPayment}
/>
</div>
</FormModal>
<ModalPaymentPanel
isOpen={isPaymentPanelOpen}
onClose={() => setIsPaymentPanelOpen(false)}
selectedInvoices={selectedFacturesForModal}
totalAmount={totalSelectedBalance}
/>
{isSaving && <ModalLoading />}
<PDFPreview open={showPreview} onClose={closePreview} data={pdfData} entreprise={CompanyInfo} />
</>
);
};
export default InvoicesPage;