954 lines
No EOL
36 KiB
TypeScript
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; |