mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-19 21:18:25 +02:00
feat(rapprochement): add supplier mail button to request invoice PDF or BL Excel
Each reconciliation row now shows a mail icon that opens the default mail client (Outlook) with the supplier address, subject and body pre-filled, asking for the invoice as PDF or the delivery note as Excel. - new client/src/lib/supplierMail.ts helper building the subject, body and mailto URL from the delivery (supplier, store, delivery date, BL number, BL amount, invoice reference) with the current user as signature - body line breaks encoded as CRLF per RFC 6068 so Outlook keeps the layout - supplier email read from the delivery join, with fallback to the suppliers list; when no email is set the button explains where to add it - button added to both the manual and validated tabs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
2 files changed
+217
-1
No files matched your search
@@ -0,0 +1,144 @@
|
|||||||
|
import { safeFormat } from "@/lib/dateUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génération des emails de relance fournisseur (rapprochement BL/Factures).
|
||||||
|
* Le lien mailto ouvre le client de messagerie par défaut du poste (Outlook)
|
||||||
|
* avec le destinataire, l'objet et le corps du message déjà préremplis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SupplierMailDelivery {
|
||||||
|
blNumber?: string | null;
|
||||||
|
blAmount?: string | number | null;
|
||||||
|
invoiceReference?: string | null;
|
||||||
|
scheduledDate?: string | Date | null;
|
||||||
|
deliveredDate?: string | Date | null;
|
||||||
|
supplier?: {
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
contact?: string | null;
|
||||||
|
} | null;
|
||||||
|
group?: { name?: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierMailSender {
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nom lisible de l'expéditeur pour la signature du mail.
|
||||||
|
*/
|
||||||
|
export function getSenderName(user?: SupplierMailSender | null): string {
|
||||||
|
if (!user) return "";
|
||||||
|
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return fullName || user.username || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objet du mail : identifie la livraison concernée.
|
||||||
|
*/
|
||||||
|
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
|
||||||
|
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
|
||||||
|
const formattedDate = deliveryDate
|
||||||
|
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const reference = delivery.blNumber
|
||||||
|
? `BL n° ${delivery.blNumber}`
|
||||||
|
: formattedDate
|
||||||
|
? `Livraison du ${formattedDate}`
|
||||||
|
: "Livraison";
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
"Demande de facture (PDF) ou BL (Excel)",
|
||||||
|
delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (delivery.group?.name) {
|
||||||
|
parts.push(delivery.group.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" - ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corps du mail : rappel des documents attendus + récapitulatif de la livraison.
|
||||||
|
* Seules les informations réellement renseignées sont listées.
|
||||||
|
*/
|
||||||
|
export function buildSupplierMailBody(
|
||||||
|
delivery: SupplierMailDelivery,
|
||||||
|
user?: SupplierMailSender | null
|
||||||
|
): string {
|
||||||
|
const deliveryDate = delivery.deliveredDate || delivery.scheduledDate;
|
||||||
|
const formattedDate = deliveryDate
|
||||||
|
? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" })
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const blAmount =
|
||||||
|
delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== ""
|
||||||
|
? parseFloat(String(delivery.blAmount))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const details: string[] = [];
|
||||||
|
if (delivery.supplier?.name) details.push(`- Fournisseur : ${delivery.supplier.name}`);
|
||||||
|
if (delivery.group?.name) details.push(`- Magasin : ${delivery.group.name}`);
|
||||||
|
if (formattedDate) details.push(`- Date de livraison : ${formattedDate}`);
|
||||||
|
if (delivery.blNumber) details.push(`- N° de BL : ${delivery.blNumber}`);
|
||||||
|
if (blAmount !== null && !isNaN(blAmount)) {
|
||||||
|
details.push(`- Montant du BL : ${blAmount.toFixed(2)} €`);
|
||||||
|
}
|
||||||
|
if (delivery.invoiceReference) {
|
||||||
|
details.push(`- Référence facture : ${delivery.invoiceReference}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderName = getSenderName(user);
|
||||||
|
const signature = [senderName, delivery.group?.name].filter(Boolean).join("\n");
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
"Bonjour,",
|
||||||
|
"",
|
||||||
|
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
|
||||||
|
"",
|
||||||
|
"- la facture au format PDF, ou",
|
||||||
|
"- le bon de livraison au format Excel",
|
||||||
|
"",
|
||||||
|
"Livraison concernée :",
|
||||||
|
...(details.length > 0 ? details : ["- (informations de livraison à préciser)"]),
|
||||||
|
"",
|
||||||
|
"Vous en remerciant par avance.",
|
||||||
|
"",
|
||||||
|
"Cordialement,",
|
||||||
|
...(signature ? [signature] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le lien mailto complet (destinataire + objet + corps préremplis).
|
||||||
|
*/
|
||||||
|
export function buildSupplierMailtoUrl(
|
||||||
|
delivery: SupplierMailDelivery,
|
||||||
|
supplierEmail: string,
|
||||||
|
user?: SupplierMailSender | null
|
||||||
|
): string {
|
||||||
|
const subject = buildSupplierMailSubject(delivery);
|
||||||
|
// RFC 6068 : les sauts de ligne du corps doivent être encodés en CRLF (%0D%0A),
|
||||||
|
// sinon Outlook peut coller les lignes les unes aux autres.
|
||||||
|
const body = buildSupplierMailBody(delivery, user).replace(/\n/g, "\r\n");
|
||||||
|
|
||||||
|
return `mailto:${supplierEmail.trim()}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ouvre le client de messagerie par défaut (Outlook sur les postes du magasin).
|
||||||
|
*/
|
||||||
|
export function openMailClient(mailtoUrl: string): void {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = mailtoUrl;
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
@@ -12,13 +12,14 @@ import { useStore } from "@/contexts/StoreContext";
|
|||||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||||
import { usePermissions } from "@shared/permissions";
|
import { usePermissions } from "@shared/permissions";
|
||||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||||
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare } from "lucide-react";
|
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, Filter, Upload, CheckCircle, XCircle, Clock, MessageSquare, Mail } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import ReconciliationComments from "@/components/ReconciliationComments";
|
import ReconciliationComments from "@/components/ReconciliationComments";
|
||||||
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
import ReconciliationModal from "@/components/modals/ReconciliationModal";
|
||||||
|
import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail";
|
||||||
|
|
||||||
export default function BLReconciliation() {
|
export default function BLReconciliation() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -607,6 +608,35 @@ export default function BLReconciliation() {
|
|||||||
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
|
return (isNotValidated || hasNoInvoiceReference) && hasValidGroup;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Email du fournisseur : donnée jointe à la livraison, avec repli sur la liste des fournisseurs
|
||||||
|
const getSupplierEmail = (delivery: any): string => {
|
||||||
|
const fromDelivery = delivery?.supplier?.email;
|
||||||
|
if (fromDelivery && String(fromDelivery).trim()) {
|
||||||
|
return String(fromDelivery).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplier = suppliers.find((s: any) => s.id === delivery?.supplierId);
|
||||||
|
return supplier?.email ? String(supplier.email).trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ouvre Outlook (client mail par défaut) avec un message prérempli
|
||||||
|
// demandant la facture au format PDF ou le BL au format Excel
|
||||||
|
const handleRequestDocumentsByEmail = (delivery: any) => {
|
||||||
|
const supplierEmail = getSupplierEmail(delivery);
|
||||||
|
|
||||||
|
if (!supplierEmail) {
|
||||||
|
toast({
|
||||||
|
title: "Email fournisseur manquant",
|
||||||
|
description: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}. Ajoutez-la depuis la page Contacts ou Fournisseurs.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail, user);
|
||||||
|
openMailClient(mailtoUrl);
|
||||||
|
};
|
||||||
|
|
||||||
const handleQuickValidate = async (delivery: any) => {
|
const handleQuickValidate = async (delivery: any) => {
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
|
await apiRequest(`/api/deliveries/${delivery.id}`, "PUT", {
|
||||||
@@ -1043,6 +1073,28 @@ export default function BLReconciliation() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
{(() => {
|
||||||
|
const supplierEmail = getSupplierEmail(delivery);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||||
|
className={`h-8 w-8 p-0 ${
|
||||||
|
supplierEmail
|
||||||
|
? 'text-blue-600 hover:text-blue-700 border-blue-300'
|
||||||
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
supplierEmail
|
||||||
|
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||||
|
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{shouldShowInvoiceButton(delivery) && (
|
{shouldShowInvoiceButton(delivery) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1351,6 +1403,26 @@ export default function BLReconciliation() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
{(() => {
|
||||||
|
const supplierEmail = getSupplierEmail(delivery);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRequestDocumentsByEmail(delivery)}
|
||||||
|
className={`transition-colors duration-200 p-1 rounded opacity-70 ${
|
||||||
|
supplierEmail
|
||||||
|
? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50'
|
||||||
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
supplierEmail
|
||||||
|
? `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
|
||||||
|
: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenCommentModal(delivery)}
|
onClick={() => handleOpenCommentModal(delivery)}
|
||||||
className={`transition-colors duration-200 p-1 rounded ${
|
className={`transition-colors duration-200 p-1 rounded ${
|
||||||
|
|||||||
Reference in new issue
Block a user