Merge pull request #563 from R0m1k3/claude/focused-volta-u3pwmj

Claude/focused volta u3pwmj
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-14 19:01:09 +02:00
commit 1b9e3ac493
82 files changed
+1654 -7005

No files matched your search

+9 -1
View File
@@ -21,4 +21,12 @@ POSTGRES_PORT=5434
# Note de sécurité:
# Les identifiants sont préconfigurés pour simplifier le déploiement.
# En production, vous pouvez les modifier si nécessaire.
# En production, vous pouvez les modifier si nécessaire.
# Chiffrement des secrets en base (mots de passe SMTP, jetons NocoDB).
# Repli sur SESSION_SECRET si absent. ATTENTION : changer cette clé rend
# les secrets déjà chiffrés illisibles.
ENCRYPTION_KEY=
# Route de secours POST /api/emergency-admin-reset : désactivée si vide.
# Ne définir qu'en cas de besoin, puis retirer.
EMERGENCY_SECRET=
+10 -1
View File
@@ -3,4 +3,13 @@ dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
*.tar.gz
# Secrets et fichiers de session : ne jamais committer
.env
.env.local
cookie*.txt
# Artefacts de debug locaux
*.log
scratch/
+3 -2
View File
@@ -27,7 +27,7 @@ RUN ls -la server/
# Build the application
# Build frontend first
RUN npx vite build
RUN NODE_ENV=production npx vite build
# Vérifier que les fichiers sont construits
RUN echo "=== BUILD VERIFICATION ===" && \
@@ -61,7 +61,8 @@ RUN npx esbuild server/index.production.ts \
--external:express-rate-limit \
--external:memoizee \
--external:nanoid \
--external:date-fns
--external:date-fns \
--external:nodemailer
# Production stage
FROM node:20-alpine AS production
-24
View File
@@ -1,24 +0,0 @@
-- MIGRATION URGENTE PRODUCTION DLC
-- Exécutez cette commande directement sur votre serveur
-- Ajout des colonnes DLC stock épuisé
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
-- Index pour performance
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
-- Initialisation des valeurs
UPDATE dlc_products
SET stock_epuise = false
WHERE stock_epuise IS NULL;
-- Vérification
SELECT
'Migration DLC terminée' as status,
COUNT(*) as colonnes_ajoutees
FROM information_schema.columns
WHERE table_name = 'dlc_products'
AND column_name IN ('stock_epuise', 'stock_epuise_by', 'stock_epuise_at');
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

@@ -1,245 +0,0 @@
Images
Networks
Volumes
Events
Host
Administration
User-related
Environment-related
Registries
Logs
Notifications
Settings
New version available 2.33.3
DismissSee what's new
Community Edition
2.27.6 LTS
Containers>logiflow-logiflow-1>Logs
Container logs
admin
Log viewer settings
Auto-refresh logs
Wrap lines
Display timestamps
Fetch
All logs
Search
Filter...
Lines
100
Actions
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Not found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 18ms
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
GET /api/groups 304 in 4ms
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
{
id: 20,
title: 'DLC',
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
type: 'success',
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
❌ [PRODUCTION] User not found with username: admin_local
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 8ms
GET /api/user 304 in 13ms
GET /api/suppliers 304 in 4ms
GET /api/dlc-products 304 in 58ms
@@ -1,245 +0,0 @@
Images
Networks
Volumes
Events
Host
Administration
User-related
Environment-related
Registries
Logs
Notifications
Settings
New version available 2.33.3
DismissSee what's new
Community Edition
2.27.6 LTS
Containers>logiflow-logiflow-1>Logs
Container logs
admin
Log viewer settings
Auto-refresh logs
Wrap lines
Display timestamps
Fetch
All logs
Search
Filter...
Lines
100
Actions
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Not found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 18ms
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
GET /api/groups 304 in 4ms
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
{
id: 20,
title: 'DLC',
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
type: 'success',
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
❌ [PRODUCTION] User not found with username: admin_local
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 8ms
GET /api/user 304 in 13ms
GET /api/suppliers 304 in 4ms
GET /api/dlc-products 304 in 58ms
@@ -1,202 +0,0 @@
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 8ms
📢 [SERVER] Fetching announcements for user: _1754936203292 environment: production
📢 [SERVER] User found: { username: 'ff292', role: 'employee' }
🎯 [PRODUCTION] Using PostgreSQL DASHBOARD_MESSAGES table
🔍 [PRODUCTION] Raw messages from DB: 4 items: [
{
id: 20,
title: 'DLC',
content: 'Attention des DLC sont en expiré bientôt ou expiré, il faut s en occuper',
type: 'success',
storeId: 2,
createdBy: 'admin',
createdAt: 2025-09-24T11:26:13.521Z
},
{
id: 17,
title: 'Module Avoir',
content: "Le module d'avoir est en place",
type: 'error',
storeId: 1,
createdBy: 'admin',
createdAt: 2025-09-09T07:47:47.650Z
},
{
id: 16,
title: 'Mise à jour Prix',
content: 'Merci de mettre vos prix a jour tous les matins, certain magasin ne le font pas.',
type: 'success',
storeId: null,
createdBy: 'admin',
createdAt: 2025-08-18T10:45:08.585Z
},
{
id: 8,
title: 'Bon de Livraison',
content: 'Lorsque vous rentrez un numéro de bon de livraison, faite le a l identique, même les majuscules. Pour My candy Shop, prendre le numéro de commande.',
type: 'info',
storeId: null,
createdBy: 'admin_local',
createdAt: 2025-08-07T11:04:38.123Z
}
]
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin
🔍 [PRODUCTION] Looking for user with username: admin_local
🔍 [PRODUCTION] User search result: Found
❌ [PRODUCTION] User not found with username: admin_local
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Found
🔍 [PRODUCTION] User search result: Not found
🎯 [PRODUCTION] Found announcements: 4
GET /api/announcements 304 in 9ms
GET /api/groups 304 in 3ms
GET /api/user 304 in 5ms
GET /api/suppliers 304 in 4ms
GET /api/dlc-products 304 in 45ms
HEAD /api/health 200 in 0ms
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

@@ -210,7 +210,7 @@ export function CustomerOrderDetails({ order }: CustomerOrderDetailsProps) {
<Badge
style={{
backgroundColor: order.group.color + "20",
color: order.group.color,
color: order.group.color || undefined,
border: `1px solid ${order.group.color}30`
}}
>
+3 -2
View File
@@ -42,13 +42,14 @@ const customerOrderFormSchema = z.object({
customerNotified: z.boolean().default(false),
notes: z.string().optional(),
customerEmail: z.string().optional(),
status: z.string().optional(),
});
type CustomerOrderFormData = z.infer<typeof customerOrderFormSchema>;
interface CustomerOrderFormProps {
order?: CustomerOrderWithRelations;
onSubmit: (data: CustomerOrderFormData) => void;
onSubmit: (data: Record<string, any>) => void;
onCancel: () => void;
isLoading?: boolean;
}
@@ -113,7 +114,7 @@ export function CustomerOrderForm({
quantity: order?.quantity || 1,
supplierId: order?.supplierId || 1,
status: "En attente de Commande", // Statut fixe
deposit: order?.deposit || 0,
deposit: order?.deposit ? Number(order.deposit) : 0,
isPromotionalPrice: order?.isPromotionalPrice || false,
customerNotified: order?.customerNotified || false,
groupId: order?.groupId || getUserAssignedGroupId() || 1,
@@ -273,7 +273,7 @@ export default function CreateDeliveryModal({
<>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: selectedGroup.color }}
style={{ backgroundColor: selectedGroup.color || undefined }}
/>
<span className="font-medium">{selectedGroup.name}</span>
</>
@@ -210,7 +210,7 @@ export default function CreateOrderModal({
<>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: selectedGroup.color }}
style={{ backgroundColor: selectedGroup.color || undefined }}
/>
<span className="font-medium">{selectedGroup.name}</span>
</>
@@ -178,7 +178,7 @@ export default function EditDeliveryModal({
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
style={{ backgroundColor: group.color || undefined }}
/>
<span>{group.name}</span>
</div>
@@ -155,7 +155,7 @@ export default function EditOrderModal({
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
style={{ backgroundColor: group.color || undefined }}
/>
<span>{group.name}</span>
</div>
+1 -1
View File
@@ -137,7 +137,7 @@ export function useAuthUnified() {
console.log('🔄 Development refetch result:', {
success: result.isSuccess,
hasData: !!result.data,
userId: result.data?.id
userId: (result.data as any)?.id
});
}
return result.data;
+1 -1
View File
@@ -1,4 +1,4 @@
import { format } from "date-fns";
import { format, type Locale } from "date-fns";
import { fr } from "date-fns/locale";
/**
-123
View File
@@ -1,123 +0,0 @@
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;
}
/**
* 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): 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 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.",
"",
// Le corps s'arrête ici : l'enseigne, le magasin et le logo sont apportés
// par la signature configurée dans Outlook sur le poste.
"Cordialement,",
];
return lines.join("\n");
}
/**
* Construit le lien mailto complet (destinataire + objet + corps préremplis).
*/
export function buildSupplierMailtoUrl(
delivery: SupplierMailDelivery,
supplierEmail: string
): 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).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);
}
-1
View File
@@ -1 +0,0 @@
// Backup temporaire pour restaurer si nécessaire
+114 -50
View File
@@ -19,7 +19,6 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import ReconciliationComments from "@/components/ReconciliationComments";
import ReconciliationModal from "@/components/modals/ReconciliationModal";
import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail";
export default function BLReconciliation() {
const { user } = useAuthUnified();
@@ -68,6 +67,8 @@ export default function BLReconciliation() {
// État pour le système de vérification de facture
const [verificationResults, setVerificationResults] = useState<Record<number, any>>({});
const [verifyingDeliveries, setVerifyingDeliveries] = useState<Set<number>>(new Set());
// Livraisons dont le mail fournisseur est en cours d'envoi
const [sendingMailDeliveries, setSendingMailDeliveries] = useState<Set<number>>(new Set());
// État pour le modal de commentaire
const [showCommentModal, setShowCommentModal] = useState(false);
@@ -78,6 +79,34 @@ export default function BLReconciliation() {
queryKey: ['/api/suppliers'],
});
// Historique des relances fournisseurs : dernier envoi affiché sur l'icône mail
const { data: supplierMailLogs = [] } = useQuery<any[]>({
queryKey: ['/api/supplier-mail-logs', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedStoreId && (user?.role === 'admin' || user?.role === 'directeur')) {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/supplier-mail-logs?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch supplier mail logs');
return response.json();
},
enabled: !!user,
});
// Dernier envoi réussi par livraison (les logs arrivent triés du plus récent au plus ancien)
const lastMailByDelivery = React.useMemo(() => {
const map = new Map<number, any>();
for (const log of supplierMailLogs) {
if (log.status === 'sent' && !map.has(log.deliveryId)) {
map.set(log.deliveryId, log);
}
}
return map;
}, [supplierMailLogs]);
// ---------------------------------------------------------------------------
// File d'attente des vérifications de facture
//
@@ -121,52 +150,31 @@ export default function BLReconciliation() {
// Auto-remplissage si facture trouvée (référence facture OU numéro BL)
if (result.exists) {
console.log('🔍 DEBUG - Résultat complet de vérification:', {
deliveryId: variables.deliveryId,
exists: result.exists,
matchType: result.matchType,
invoiceReference: result.invoiceReference,
invoiceAmount: result.invoiceAmount,
dueDate: result.dueDate,
hasInvoiceRef: !!result.invoiceReference,
hasAmount: result.invoiceAmount !== undefined && result.invoiceAmount !== null,
hasDueDate: !!result.dueDate
});
// Auto-remplir les champs dans la livraison via API
const updateData: any = {};
// Ajouter la référence de facture SEULEMENT si trouvée via BL (pas déjà renseignée)
if (result.invoiceReference && result.matchType === 'bl_number') {
updateData.invoiceReference = result.invoiceReference;
console.log('✅ Ajout invoiceReference:', result.invoiceReference);
} else {
console.log('⚠️ Pas d\'ajout invoiceReference:', { hasRef: !!result.invoiceReference, matchType: result.matchType });
}
// TOUJOURS mettre à jour le montant si disponible (peu importe le matchType)
if (result.invoiceAmount !== undefined && result.invoiceAmount !== null) {
updateData.invoiceAmount = result.invoiceAmount;
console.log('✅ Ajout invoiceAmount:', result.invoiceAmount);
} else {
console.log('⚠️ Pas d\'ajout invoiceAmount:', { amount: result.invoiceAmount });
}
// TOUJOURS mettre à jour la date d'échéance si disponible (peu importe le matchType)
if (result.dueDate) {
updateData.dueDate = result.dueDate;
console.log('✅ Ajout dueDate:', result.dueDate);
} else {
console.log('⚠️ Pas d\'ajout dueDate:', { dueDate: result.dueDate });
}
console.log('📝 Données finales à sauvegarder:', { deliveryId: variables.deliveryId, updateData, matchType: result.matchType });
if (import.meta.env.DEV) {
console.log('📝 Auto-remplissage:', { deliveryId: variables.deliveryId, updateData, matchType: result.matchType });
}
// Ne faire l'appel que si on a des données à mettre à jour
if (Object.keys(updateData).length > 0) {
apiRequest(`/api/deliveries/${variables.deliveryId}`, "PUT", updateData)
.then(() => {
console.log('✅ Données sauvegardées avec succès');
// Invalidation différée : sinon chaque auto-remplissage relance un
// refetch complet de la liste, qui relance l'effet, qui relance des
// vérifications... jusqu'à saturer le navigateur.
@@ -175,8 +183,6 @@ export default function BLReconciliation() {
.catch((error) => {
console.error('❌ Erreur auto-remplissage:', error);
});
} else {
console.log('⚠️ Aucune donnée à sauvegarder');
}
}
@@ -676,8 +682,36 @@ export default function BLReconciliation() {
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
// Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur.
// Le mail part du serveur SMTP configuré sur la fiche du magasin.
const sendSupplierMailMutation = useMutation({
mutationFn: async (delivery: any) => {
return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST');
},
onSuccess: (result: any) => {
toast({
title: "Mail envoyé",
description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`,
});
// Rafraîchir l'historique pour marquer la ligne comme relancée
queryClient.invalidateQueries({ queryKey: ['/api/supplier-mail-logs'] });
},
onError: (error: any) => {
toast({
title: "Envoi impossible",
description: error?.message || "Le mail n'a pas pu être envoyé",
variant: "destructive",
});
},
onSettled: (_result, _error, delivery: any) => {
setSendingMailDeliveries(prev => {
const next = new Set(prev);
next.delete(delivery.id);
return next;
});
},
});
const handleRequestDocumentsByEmail = (delivery: any) => {
const supplierEmail = getSupplierEmail(delivery);
@@ -690,8 +724,12 @@ export default function BLReconciliation() {
return;
}
const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail);
openMailClient(mailtoUrl);
// Envoi direct : on bloque la ligne le temps de la requête pour éviter
// qu'un double clic ne déclenche deux mails au même fournisseur.
if (sendingMailDeliveries.has(delivery.id)) return;
setSendingMailDeliveries(prev => new Set(prev).add(delivery.id));
sendSupplierMailMutation.mutate(delivery);
};
const handleQuickValidate = async (delivery: any) => {
@@ -1132,23 +1170,36 @@ export default function BLReconciliation() {
<div className="flex items-center justify-end space-x-2">
{(() => {
const supplierEmail = getSupplierEmail(delivery);
const isSending = sendingMailDeliveries.has(delivery.id);
const lastMail = lastMailByDelivery.get(delivery.id);
return (
<Button
variant="outline"
size="sm"
disabled={isSending}
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'
!supplierEmail
? 'text-gray-400 hover:text-gray-500'
: lastMail
? 'text-green-600 hover:text-green-700 border-green-300'
: 'text-blue-600 hover:text-blue-700 border-blue-300'
}`}
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'}`
isSending
? "Envoi en cours..."
: !supplierEmail
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
: lastMail
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
}
>
<Mail className="h-4 w-4" />
{isSending ? (
<Clock className="h-4 w-4 animate-spin" />
) : (
<Mail className={`h-4 w-4 ${lastMail ? 'fill-green-100' : ''}`} />
)}
</Button>
);
})()}
@@ -1462,21 +1513,34 @@ export default function BLReconciliation() {
<div className="flex items-center justify-end space-x-2">
{(() => {
const supplierEmail = getSupplierEmail(delivery);
const isSending = sendingMailDeliveries.has(delivery.id);
const lastMail = lastMailByDelivery.get(delivery.id);
return (
<button
disabled={isSending}
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'
!supplierEmail
? 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
: lastMail
? 'text-green-600 hover:text-green-700 hover:bg-green-50'
: 'text-blue-600 hover:text-blue-700 hover:bg-blue-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'}`
isSending
? "Envoi en cours..."
: !supplierEmail
? `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
: lastMail
? `Demande déjà envoyée le ${safeFormat(lastMail.createdAt, 'dd/MM/yyyy à HH:mm')} par ${lastMail.sentByName || lastMail.sentBy}. Cliquer pour renvoyer.`
: `Demander la facture (PDF) ou le BL (Excel) à ${delivery.supplier?.name || 'ce fournisseur'} (${supplierEmail})`
}
>
<Mail className="w-4 h-4" />
{isSending ? (
<Clock className="w-4 h-4 animate-spin" />
) : (
<Mail className={`w-4 h-4 ${lastMail ? 'fill-green-100' : ''}`} />
)}
</button>
);
})()}
-778
View File
@@ -1,778 +0,0 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { safeFormat } from "@/lib/dateUtils";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { useStore } from "@/contexts/StoreContext";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { usePermissions } from "@shared/permissions";
import { Search, Edit, FileText, Settings, Eye, AlertTriangle, X, Check, Trash2, Ban, ChevronLeft, ChevronRight } from "lucide-react";
import type { Supplier } from "@shared/schema";
export default function BLReconciliation() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
const permissions = usePermissions(user?.role);
// Vérification des permissions d'accès au module
if (!permissions.canView('reconciliation')) {
return (
<div className="p-6">
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<FileText className="h-5 w-5 text-orange-400" />
</div>
<div className="ml-3">
<p className="text-sm text-orange-700">
<strong>Accès restreint</strong><br />
Seuls les directeurs et administrateurs peuvent accéder au module de rapprochement BL/Factures.
</p>
</div>
</div>
</div>
</div>
);
}
const [activeTab, setActiveTab] = useState("manual");
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// Récupérer les fournisseurs pour la logique automatique
const { data: suppliers = [], isLoading: suppliersLoading } = useQuery<Supplier[]>({
queryKey: ['/api/suppliers'],
});
// Récupérer les livraisons validées avec BL
const { data: deliveriesWithBL = [], isLoading } = useQuery({
queryKey: ['/api/deliveries/bl', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams({});
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/deliveries?${params.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch deliveries');
}
const deliveries = await response.json();
const filtered = Array.isArray(deliveries) ? deliveries.filter((d: any) => d.status === 'delivered') : [];
return filtered.sort((a: any, b: any) => new Date(b.deliveredDate || b.updatedAt).getTime() - new Date(a.deliveredDate || a.updatedAt).getTime());
},
});
// Séparer les livraisons selon le mode de rapprochement du fournisseur
const manualReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
return !supplier?.automaticReconciliation;
});
const automaticReconciliationDeliveries = deliveriesWithBL.filter((delivery: any) => {
const supplier = suppliers.find((s: any) => s.id === delivery.supplierId);
return supplier?.automaticReconciliation;
});
// Mutations pour les actions
const validateManualMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: true,
validatedAt: new Date().toISOString()
});
},
onSuccess: () => {
toast({
title: "Succès",
description: "Rapprochement validé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de valider le rapprochement",
variant: "destructive",
});
}
});
const devalidateMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "PUT", {
reconciled: false,
validatedAt: null
});
},
onSuccess: () => {
toast({
title: "Succès",
description: "Rapprochement dévalidé avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de dévalider le rapprochement",
variant: "destructive",
});
}
});
const deleteMutation = useMutation({
mutationFn: async (deliveryId: number) => {
return await apiRequest(`/api/deliveries/${deliveryId}`, "DELETE");
},
onSuccess: () => {
toast({
title: "Succès",
description: "Ligne supprimée avec succès",
});
queryClient.invalidateQueries({ queryKey: ['/api/deliveries/bl'] });
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de supprimer la ligne",
variant: "destructive",
});
}
});
// Fonctions de gestion des actions
const handleValidateManual = (deliveryId: number) => {
if (!permissions.canValidate('reconciliation')) {
toast({
title: "Accès refusé",
description: "Vous n'avez pas les permissions pour valider les rapprochements",
variant: "destructive",
});
return;
}
validateManualMutation.mutate(deliveryId);
};
const handleDevalidate = (deliveryId: number) => {
if (!permissions.canEdit('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent dévalider les rapprochements",
variant: "destructive",
});
return;
}
if (confirm("Êtes-vous sûr de vouloir dévalider ce rapprochement ?")) {
devalidateMutation.mutate(deliveryId);
}
};
const handleDelete = (deliveryId: number) => {
if (!permissions.canDelete('reconciliation')) {
toast({
title: "Accès refusé",
description: "Seuls les administrateurs peuvent supprimer les lignes",
variant: "destructive",
});
return;
}
if (confirm("Êtes-vous sûr de vouloir supprimer cette ligne ? Cette action est irréversible.")) {
deleteMutation.mutate(deliveryId);
}
};
// Filtrage des livraisons par recherche
const filterDeliveries = (deliveries: any[]) => {
return deliveries.filter((delivery: any) => {
const searchLower = searchTerm.toLowerCase();
return (
delivery.supplier?.name?.toLowerCase().includes(searchLower) ||
delivery.blNumber?.toLowerCase().includes(searchLower) ||
delivery.invoiceReference?.toLowerCase().includes(searchLower)
);
});
};
const filteredManualDeliveries = filterDeliveries(manualReconciliationDeliveries);
const filteredAutomaticDeliveries = filterDeliveries(automaticReconciliationDeliveries);
// Pagination
const getCurrentPageData = (data: any[]) => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return data.slice(startIndex, endIndex);
};
const getTotalPages = (data: any[]) => {
return Math.ceil(data.length / itemsPerPage);
};
const currentManualData = getCurrentPageData(filteredManualDeliveries);
const currentAutomaticData = getCurrentPageData(filteredAutomaticDeliveries);
const totalManualPages = getTotalPages(filteredManualDeliveries);
const totalAutomaticPages = getTotalPages(filteredAutomaticDeliveries);
// Reset page when changing tabs or searching
useEffect(() => {
setCurrentPage(1);
}, [activeTab, searchTerm]);
// Vérifier si une ligne est dévalidée (pour la coloration)
const isDevalidated = (delivery: any) => {
return delivery.reconciled === false && delivery.validatedAt === null;
};
// Composant de pagination simplifiée
const NativePagination = ({ currentPage, totalPages, onPageChange, totalItems, itemsPerPage }: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems: number;
itemsPerPage: number;
}) => {
if (totalPages <= 1) return null;
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200">
<div className="flex items-center text-sm text-gray-700">
<span>
Affichage de {startItem} à {endItem} sur {totalItems} éléments
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<ChevronLeft className="w-4 h-4" />
<span>Précédent</span>
</button>
<span className="px-3 py-1 text-sm bg-gray-100 rounded">
Page {currentPage} sur {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<span>Suivant</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
};
// Badge native
const NativeBadge = ({ children, variant = "default", className = "" }: { children: React.ReactNode, variant?: string, className?: string }) => {
const baseClasses = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium";
const variantClasses = {
default: "bg-blue-100 text-blue-800",
secondary: "bg-gray-100 text-gray-800",
destructive: "bg-red-100 text-red-800",
outline: "border border-gray-300 text-gray-700 bg-white"
};
return (
<span className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${className}`}>
{children}
</span>
);
};
// Button native
const NativeButton = ({ children, onClick, disabled = false, variant = "default", size = "default", className = "", title = "" }: {
children: React.ReactNode,
onClick?: () => void,
disabled?: boolean,
variant?: string,
size?: string,
className?: string,
title?: string
}) => {
const baseClasses = "inline-flex items-center justify-center font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
const variantClasses = {
default: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100",
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
};
const sizeClasses = {
default: "px-4 py-2 text-sm",
sm: "px-2 py-1 text-xs"
};
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`${baseClasses} ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default} ${sizeClasses[size as keyof typeof sizeClasses] || sizeClasses.default} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
>
{children}
</button>
);
};
if (isLoading || suppliersLoading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header avec onglets */}
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
<FileText className="w-6 h-6 mr-3 text-blue-600" />
Rapprochement BL/Factures
</h2>
<p className="text-gray-600 mt-1">
Gestion des rapprochements manuels et automatiques
</p>
</div>
<div className="flex items-center space-x-4">
<NativeBadge variant="outline" className="text-sm border border-gray-300">
{manualReconciliationDeliveries.length} rapprochements manuels
</NativeBadge>
<NativeBadge variant="outline" className="text-sm border border-gray-300 bg-blue-50">
{automaticReconciliationDeliveries.length} rapprochements automatiques
</NativeBadge>
</div>
</div>
{/* Onglets natifs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab("manual")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
activeTab === "manual"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Edit className="w-4 h-4" />
<span>Rapprochement Manuel</span>
<NativeBadge variant="secondary" className="ml-2">
{manualReconciliationDeliveries.length}
</NativeBadge>
</button>
<button
onClick={() => setActiveTab("automatic")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 ${
activeTab === "automatic"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Settings className="w-4 h-4" />
<span>Rapprochement Automatique</span>
<NativeBadge variant="secondary" className="ml-2">
{automaticReconciliationDeliveries.length}
</NativeBadge>
</button>
</nav>
</div>
</div>
{/* Filtre de recherche */}
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher par fournisseur, BL ou facture..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Contenu des onglets */}
{activeTab === "manual" && (
<div className="space-y-6">
{filteredManualDeliveries.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Aucun rapprochement manuel trouvé
</h3>
<p className="text-gray-600">
Les livraisons de fournisseurs en mode manuel apparaîtront ici.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
{/* Pagination du haut */}
<NativePagination
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
<div className="table-container">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fournisseur</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant Fact.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentManualData.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
delivery.reconciled ? 'bg-green-50' :
isDevalidated(delivery) ? 'bg-red-50' : ''
}`}
>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.blNumber || (
<span className="text-gray-400 italic text-xs">Non renseigné</span>
)}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900">
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
<span className="text-gray-400 italic text-xs">Non renseigné</span>
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceReference || (
<span className="text-gray-400 italic text-xs">Non renseigné</span>
)}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceAmount ?
`${parseFloat(delivery.invoiceAmount).toFixed(2)}€` :
<span className="text-gray-400 italic text-xs">Non renseigné</span>
}
</div>
</td>
<td className="px-3 py-2 text-sm">
{delivery.reconciled ? (
<NativeBadge variant="default" className="bg-green-600 text-white">
Validé
</NativeBadge>
) : isDevalidated(delivery) ? (
<NativeBadge variant="destructive">
Dévalidé
</NativeBadge>
) : (
<NativeBadge variant="outline">
En attente
</NativeBadge>
)}
</td>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
{permissions.canEdit('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
>
<Edit className="w-4 h-4" />
</NativeButton>
)}
{!delivery.reconciled && permissions.canValidate('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
>
<Check className="w-4 h-4" />
</NativeButton>
)}
{delivery.reconciled && permissions.canEdit('reconciliation') && user?.role === 'admin' && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDevalidate(delivery.id)}
disabled={devalidateMutation.isPending}
className="text-orange-600 hover:text-orange-700 p-1"
title="Dévalider"
>
<Ban className="w-4 h-4" />
</NativeButton>
)}
{permissions.canDelete('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDelete(delivery.id)}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 p-1"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</NativeButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<NativePagination
currentPage={currentPage}
totalPages={totalManualPages}
onPageChange={setCurrentPage}
totalItems={filteredManualDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
)}
{activeTab === "automatic" && (
<div className="space-y-6">
{/* Message d'information */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900">Mode rapprochement automatique</h4>
<p className="text-sm text-blue-700 mt-1">
Les livraisons de fournisseurs en mode automatique sont validées automatiquement lorsqu'elles ont le statut "delivered" et un numéro de BL.
{permissions.canEdit('reconciliation') ? (
" Vous pouvez dévalider ces rapprochements si nécessaire."
) : (
" Seuls les directeurs et administrateurs peuvent modifier ces rapprochements."
)}
</p>
</div>
</div>
</div>
{filteredAutomaticDeliveries.length === 0 ? (
<div className="text-center py-12">
<Settings className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Aucun rapprochement automatique trouvé
</h3>
<p className="text-gray-600">
Les livraisons de fournisseurs en mode automatique apparaîtront ici.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden rounded-lg">
{/* Pagination du haut */}
<NativePagination
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
<div className="table-container">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fournisseur
<NativeBadge variant="secondary" className="ml-2 text-xs">AUTO</NativeBadge>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Livr.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Valid.</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Montant BL</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ref. Facture</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentAutomaticData.map((delivery: any) => (
<tr
key={delivery.id}
className={`hover:bg-gray-50 ${
isDevalidated(delivery) ? 'bg-red-50' : 'bg-green-50'
}`}
>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
<div className="font-medium text-gray-900 truncate max-w-32">
{delivery.supplier?.name}
</div>
<div className="w-3 h-3 bg-blue-500 rounded-full" title="Mode automatique" />
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900 font-medium">
{delivery.blNumber || '-'}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{safeFormat(delivery.scheduledDate, 'dd/MM/yy')}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.reconciled && delivery.validatedAt ?
safeFormat(delivery.validatedAt, 'dd/MM/yy') :
'-'
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="font-medium text-gray-900">
{delivery.blAmount ?
`${parseFloat(delivery.blAmount).toFixed(2)}€` :
'-'
}
</div>
</td>
<td className="px-3 py-2 text-sm">
<div className="text-gray-900">
{delivery.invoiceReference || '-'}
</div>
</td>
<td className="px-3 py-2 text-sm">
{isDevalidated(delivery) ? (
<NativeBadge variant="destructive">
Dévalidé
</NativeBadge>
) : (
<NativeBadge variant="default" className="bg-green-600 text-white">
Auto-validé
</NativeBadge>
)}
</td>
<td className="px-3 py-2 text-sm">
<div className="flex items-center space-x-2">
{isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<>
<NativeButton
variant="ghost"
size="sm"
className="text-gray-600 hover:text-blue-600 p-1"
title="Modifier"
>
<Edit className="w-4 h-4" />
</NativeButton>
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleValidateManual(delivery.id)}
disabled={validateManualMutation.isPending}
className="text-green-600 hover:text-green-700 p-1"
title="Valider"
>
<Check className="w-4 h-4" />
</NativeButton>
</>
)}
{!isDevalidated(delivery) && permissions.canEdit('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDevalidate(delivery.id)}
disabled={devalidateMutation.isPending}
className="text-orange-600 hover:text-orange-700 p-1"
title="Dévalider"
>
<Ban className="w-4 h-4" />
</NativeButton>
)}
{permissions.canDelete('reconciliation') && (
<NativeButton
variant="ghost"
size="sm"
onClick={() => handleDelete(delivery.id)}
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 p-1"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
</NativeButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination du bas */}
<NativePagination
currentPage={currentPage}
totalPages={totalAutomaticPages}
onPageChange={setCurrentPage}
totalItems={filteredAutomaticDeliveries.length}
itemsPerPage={itemsPerPage}
/>
</div>
)}
</div>
)}
</div>
);
}
+10 -10
View File
@@ -61,7 +61,7 @@ export default function DlcPage() {
}, [searchTerm]);
// Fetch stores/groups - optimized cache
const { data: stores = [] } = useQuery({
const { data: stores = [] } = useQuery<any[]>({
queryKey: ["/api/groups"],
enabled: !authLoading,
staleTime: 5 * 60 * 1000, // 5 minutes cache
@@ -348,18 +348,18 @@ export default function DlcPage() {
console.log("🏪 DLC GroupId Selection DEBUG:", {
userRole: user?.role,
selectedStoreId,
userGroups: user?.userGroups?.map(ug => ({groupId: ug.groupId, groupName: ug.group?.name})),
availableStores: stores.map(s => ({id: s.id, name: s.name})),
userGroups: user?.userGroups?.map((ug: any) => ({groupId: ug.groupId, groupName: ug.group?.name})),
availableStores: stores.map((s: any) => ({id: s.id, name: s.name})),
userGroupsRaw: user?.userGroups,
firstUserGroup: user?.userGroups?.[0],
finalGroupId: groupId,
logicPath: !groupId ? 'need-fallback' : 'already-set'
});
const dlcData: InsertDlcProduct = {
const dlcData: any = {
...data,
name: data.productName, // Copier productName vers name (requis dans la DB)
expiryDate: dlcDate,
expiryDate: dlcDate as any,
quantity: 1, // Valeur par défaut
unit: "unité", // Valeur par défaut
location: "Magasin", // Valeur par défaut
@@ -377,7 +377,7 @@ export default function DlcPage() {
const handleEdit = (product: DlcProductWithRelations) => {
setEditingProduct(product);
form.reset({
productName: product.productName,
productName: product.productName as string,
gencode: product.gencode || "",
dlcDate: product.expiryDate ? format(new Date(product.expiryDate), "yyyy-MM-dd") : "",
dateType: product.dateType as "dlc" | "ddm" | "dluo",
@@ -526,7 +526,7 @@ export default function DlcPage() {
</tr>
</thead>
<tbody>
${expiringSoon.map(product => {
${expiringSoon.map((product: any) => {
const today = new Date();
const expiry = new Date(product.dlcDate || new Date());
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
@@ -585,7 +585,7 @@ export default function DlcPage() {
</tr>
</thead>
<tbody>
${expired.map(product => {
${expired.map((product: any) => {
const today = new Date();
const expiry = new Date(product.dlcDate || new Date());
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
@@ -620,14 +620,14 @@ export default function DlcPage() {
// Memoized calculations for print functions
const { expiringSoonProducts, expiredProducts } = useMemo(() => {
const today = new Date();
const expiringSoon = filteredProducts.filter(product => {
const expiringSoon = filteredProducts.filter((product: any) => {
if (product.status === 'valides') return false;
const expiryDate = new Date(product.dlcDate || new Date());
const diffDays = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
return diffDays <= 15 && diffDays > 0;
});
const expired = filteredProducts.filter(product => {
const expired = filteredProducts.filter((product: any) => {
if (product.status === 'valides') return false;
const expiryDate = new Date(product.dlcDate || new Date());
return expiryDate <= today;
+311 -90
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useToast } from "@/hooks/use-toast";
@@ -33,6 +34,38 @@ const colorOptions = [
{ value: '#00796B', label: 'Sarcelle' },
];
// Taille maximale du logo magasin : il est encodé en base64 dans la fiche
// et joint à chaque mail, un fichier lourd alourdirait tous les envois.
const MAX_LOGO_KB = 200;
// Valeurs par défaut du formulaire magasin (création et réinitialisations)
const EMPTY_GROUP_FORM = {
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
// Coordonnées reprises dans la signature des mails fournisseurs
address: "",
phone: "",
logo: "",
// Configuration SMTP propre au magasin
smtpEnabled: false,
smtpHost: "",
smtpPort: "",
smtpSecure: false,
smtpUser: "",
smtpPassword: "",
smtpSenderEmail: "",
smtpSenderName: "",
};
export default function Groups() {
const { user } = useAuthUnified();
const { toast } = useToast();
@@ -42,19 +75,7 @@ export default function Groups() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [formData, setFormData] = useState({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM });
const { data: groups = [], isLoading } = useQuery<Group[]>({
queryKey: ['/api/groups'],
@@ -66,16 +87,6 @@ export default function Groups() {
// Protection renforcée contre les erreurs TypeError
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
console.log('🔍 Groups NocoDB Debug:', {
rawNocodbConfigs,
rawType: typeof rawNocodbConfigs,
nocodbConfigs,
configsType: typeof nocodbConfigs,
isArray: Array.isArray(rawNocodbConfigs),
length: nocodbConfigs.length,
environment: window.location.hostname
});
// Comptages agrégés en base : la page n'affiche que des totaux, inutile de
// rapatrier l'historique complet des commandes et des livraisons.
@@ -97,19 +108,7 @@ export default function Groups() {
});
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
setShowCreateModal(false);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
},
onError: (error) => {
if (isUnauthorizedError(error)) {
@@ -143,19 +142,7 @@ export default function Groups() {
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
},
onError: (error) => {
if (isUnauthorizedError(error)) {
@@ -177,6 +164,53 @@ export default function Groups() {
},
});
// Test de la connexion SMTP du magasin (aucun message envoyé)
const testSmtpMutation = useMutation({
mutationFn: async (groupId: number) => {
return await apiRequest(`/api/groups/${groupId}/test-smtp`, 'POST');
},
onSuccess: (result: any) => {
toast({
title: "Connexion réussie",
description: result?.message || "Le serveur SMTP répond correctement",
});
},
onError: (error: any) => {
toast({
title: "Échec de la connexion",
description: error?.message || "Impossible de joindre le serveur SMTP",
variant: "destructive",
});
},
});
// Lecture du logo en data URI : stocké tel quel sur la fiche magasin
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_LOGO_KB * 1024) {
toast({
title: "Logo trop volumineux",
description: `Le fichier fait ${Math.round(file.size / 1024)} Ko. Maximum autorisé : ${MAX_LOGO_KB} Ko.`,
variant: "destructive",
});
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => handleChange('logo', String(reader.result || ''));
reader.onerror = () => {
toast({
title: "Lecture impossible",
description: "Le fichier n'a pas pu être lu",
variant: "destructive",
});
};
reader.readAsDataURL(file);
};
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await apiRequest(`/api/groups/${id}`, "DELETE");
@@ -225,19 +259,7 @@ export default function Groups() {
};
const handleCreate = () => {
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
setShowCreateModal(true);
};
@@ -255,6 +277,18 @@ export default function Groups() {
nocodbSupplierColumnName: group.nocodbSupplierColumnName || "",
nocodbDueDateColumnName: group.nocodbDueDateColumnName || "",
webhookUrl: group.webhookUrl || "",
address: (group as any).address || "",
phone: (group as any).phone || "",
logo: (group as any).logo || "",
smtpEnabled: Boolean((group as any).smtpEnabled),
smtpHost: (group as any).smtpHost || "",
smtpPort: (group as any).smtpPort?.toString() || "",
smtpSecure: Boolean((group as any).smtpSecure),
smtpUser: (group as any).smtpUser || "",
// Jamais renvoyé par l'API : vide = mot de passe inchangé
smtpPassword: "",
smtpSenderEmail: (group as any).smtpSenderEmail || "",
smtpSenderName: (group as any).smtpSenderName || "",
});
setShowEditModal(true);
};
@@ -289,11 +323,17 @@ export default function Groups() {
}
// Prepare data with proper type conversion
const submitData = {
const submitData: any = {
...formData,
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
smtpPort: formData.smtpPort ? parseInt(formData.smtpPort) : null,
};
// Mot de passe laissé vide = on conserve celui déjà enregistré
if (!formData.smtpPassword) {
delete submitData.smtpPassword;
}
if (selectedGroup) {
updateMutation.mutate(submitData);
} else {
@@ -301,7 +341,7 @@ export default function Groups() {
}
};
const handleChange = (field: string, value: string) => {
const handleChange = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
@@ -384,7 +424,7 @@ export default function Groups() {
<div className="flex items-center">
<div
className="w-10 h-10 flex items-center justify-center mr-3"
style={{ backgroundColor: group.color }}
style={{ backgroundColor: group.color || undefined }}
>
<Users className="w-5 h-5 text-white" />
</div>
@@ -419,7 +459,7 @@ export default function Groups() {
<div className="flex items-center">
<div
className="w-4 h-4 rounded mr-2"
style={{ backgroundColor: group.color }}
style={{ backgroundColor: group.color || undefined }}
/>
<span className="text-sm text-gray-600">{group.color}</span>
</div>
@@ -463,14 +503,7 @@ export default function Groups() {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableId: "",
nocodbTableName: "",
invoiceColumnName: "Ref Facture",
});
setFormData({ ...EMPTY_GROUP_FORM, invoiceColumnName: "Ref Facture" });
}}>
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
<DialogHeader>
@@ -683,27 +716,215 @@ export default function Groups() {
)}
</div>
{/* Section Coordonnées du magasin (signature des mails) */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center space-x-2 mb-1">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<h3 className="font-medium text-gray-900">Coordonnées du magasin</h3>
</div>
<p className="text-sm text-gray-600">
Reprises dans la signature des mails envoyés aux fournisseurs.
</p>
<div>
<Label htmlFor="address">Adresse</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder={"12 rue des Fleurs\n54000 Nancy"}
rows={2}
/>
</div>
<div>
<Label htmlFor="phone">Téléphone</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="03 83 00 00 00"
/>
</div>
<div>
<Label htmlFor="logo">Logo</Label>
<div className="flex items-center space-x-3 mt-1">
{formData.logo ? (
<img
src={formData.logo}
alt="Logo du magasin"
className="h-12 w-auto max-w-[120px] object-contain border rounded bg-white p-1"
/>
) : (
<div className="h-12 w-[120px] border border-dashed rounded flex items-center justify-center text-xs text-gray-400">
Aucun logo
</div>
)}
<div className="space-y-1">
<Input
id="logo"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleLogoChange}
className="text-xs"
/>
{formData.logo && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-red-600 hover:text-red-700"
onClick={() => handleChange('logo', '')}
>
Retirer le logo
</Button>
)}
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
PNG ou JPEG, {MAX_LOGO_KB} Ko maximum. Affiché dans la signature des mails.
</p>
</div>
</div>
{/* Section Configuration SMTP du magasin */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-purple-500 rounded"></div>
<h3 className="font-medium text-gray-900">Envoi de mails (SMTP)</h3>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpEnabled}
onChange={(e) => handleChange('smtpEnabled', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Activé</span>
</label>
</div>
<p className="text-sm text-gray-600">
Serveur utilisé pour envoyer les demandes de facture aux fournisseurs
depuis le rapprochement. Chaque magasin utilise sa propre boîte mail.
</p>
{formData.smtpEnabled && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label htmlFor="smtpHost">Serveur SMTP *</Label>
<Input
id="smtpHost"
value={formData.smtpHost}
onChange={(e) => handleChange('smtpHost', e.target.value)}
placeholder="smtp.office365.com"
/>
</div>
<div>
<Label htmlFor="smtpPort">Port *</Label>
<Input
id="smtpPort"
type="number"
value={formData.smtpPort}
onChange={(e) => handleChange('smtpPort', e.target.value)}
placeholder="587"
/>
</div>
</div>
<label className="flex items-center space-x-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={formData.smtpSecure}
onChange={(e) => handleChange('smtpSecure', e.target.checked)}
className="rounded border-gray-300"
/>
<span>Connexion SSL/TLS directe (port 465). Décoché = STARTTLS (port 587).</span>
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpUser">Identifiant</Label>
<Input
id="smtpUser"
value={formData.smtpUser}
onChange={(e) => handleChange('smtpUser', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="smtpPassword">Mot de passe</Label>
<Input
id="smtpPassword"
type="password"
value={formData.smtpPassword}
onChange={(e) => handleChange('smtpPassword', e.target.value)}
placeholder={
selectedGroup && (selectedGroup as any).smtpPasswordSet
? "Inchangé"
: "Mot de passe"
}
autoComplete="new-password"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="smtpSenderEmail">Adresse expéditeur *</Label>
<Input
id="smtpSenderEmail"
type="email"
value={formData.smtpSenderEmail}
onChange={(e) => handleChange('smtpSenderEmail', e.target.value)}
placeholder="magasin@lafoirfouille.fr"
/>
</div>
<div>
<Label htmlFor="smtpSenderName">Nom affiché</Label>
<Input
id="smtpSenderName"
value={formData.smtpSenderName}
onChange={(e) => handleChange('smtpSenderName', e.target.value)}
placeholder={formData.name || "LaFoir'Fouille Nancy"}
/>
</div>
</div>
{selectedGroup && (
<div>
<Button
type="button"
variant="outline"
className="w-full"
disabled={testSmtpMutation.isPending}
onClick={() => testSmtpMutation.mutate(selectedGroup.id)}
>
{testSmtpMutation.isPending
? "Test en cours..."
: "✉️ Tester la connexion SMTP"}
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Vérifie le serveur et les identifiants enregistrés, sans envoyer de message
</p>
</div>
)}
</>
)}
</div>
<div className="flex items-center space-x-3 pt-4">
<Button
type="button"
variant="outline"
<Button
type="button"
variant="outline"
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedGroup(null);
setFormData({
name: "",
color: "#1976D2",
nocodbConfigId: "",
nocodbTableName: "",
invoiceColumnName: "",
nocodbBlColumnName: "",
nocodbAmountColumnName: "",
nocodbInvoiceAmountTTCColumnName: "",
nocodbSupplierColumnName: "",
nocodbDueDateColumnName: "",
webhookUrl: "",
});
setFormData({ ...EMPTY_GROUP_FORM });
}}
>
Annuler
+1 -1
View File
@@ -183,7 +183,7 @@ export default function NocoDBConfig() {
apiToken: config.apiToken,
projectId: config.projectId,
description: config.description || "",
isActive: config.isActive,
isActive: config.isActive ?? undefined,
});
setShowEditModal(true);
};
-568
View File
@@ -1,568 +0,0 @@
import { useState, useEffect } from "react";
// Interface pour les tâches
interface Task {
id: number;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
status: 'pending' | 'completed';
assignedTo: string;
startDate?: string | null;
dueDate?: string | null;
createdAt?: string;
groupId?: number;
createdBy?: string;
}
export default function TasksListSimple() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [showCreateForm, setShowCreateForm] = useState(false);
// Charger les tâches
const loadTasks = async () => {
try {
const response = await fetch('/api/tasks');
if (response.ok) {
const data = await response.json();
setTasks(data);
} else {
console.error('Erreur lors du chargement des tâches:', response.status);
}
} catch (error) {
console.error('Erreur lors du chargement des tâches:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
// Créer une tâche
const createTask = async (formData: FormData) => {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return false;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return false;
}
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: status || "pending",
assignedTo: assignedTo.trim(),
startDate: startDate || null,
dueDate: dueDate || null,
groupId: 1,
createdBy: 'admin',
};
console.log('📤 Creating task:', taskData);
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Create Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert("Tâche créée avec succès");
loadTasks();
return true;
} catch (error) {
console.error('Erreur lors de la création:', error);
alert("Erreur lors de la création");
return false;
}
};
// Modifier une tâche
const updateTask = async (id: number, formData: FormData) => {
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return false;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return false;
}
try {
// Préparer les données exactement comme le serveur les attend
const taskData: Partial<Task> = {
title: title.trim(),
description: description?.trim() || "",
priority: (priority as Task['priority']) || "medium",
status: (status as Task['status']) || "pending",
assignedTo: assignedTo.trim(),
};
// Gestion spéciale des dates pour éviter les erreurs de format
if (startDate && startDate.trim() !== '') {
taskData.startDate = startDate;
} else {
taskData.startDate = null;
}
if (dueDate && dueDate.trim() !== '') {
taskData.dueDate = dueDate;
} else {
taskData.dueDate = null;
}
console.log('📤 Updating task:', { id, taskData });
const response = await fetch(`/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
console.log('📥 Update Response:', { status: response.status, ok: response.ok });
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Update Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert("Tâche modifiée avec succès");
setEditingId(null);
loadTasks();
return true;
} catch (error) {
console.error('Erreur lors de la modification:', error);
alert("Erreur lors de la modification");
return false;
}
};
// Filtrer les tâches
const filteredTasks = tasks.filter(task => {
const matchesSearch = !searchTerm || task.title?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Formulaire inline
const TaskForm = ({ task, onSubmit, onCancel }: {
task?: Task;
onSubmit: (formData: FormData) => Promise<boolean>;
onCancel: () => void;
}) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const success = await onSubmit(formData);
if (success && !task) {
(e.target as HTMLFormElement).reset();
}
};
return (
<tr style={{ backgroundColor: '#f9fafb' }}>
<td colSpan={6} style={{ padding: '16px' }}>
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Titre *
</label>
<input
name="title"
type="text"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Description
</label>
<input
name="description"
type="text"
defaultValue={task?.description || ""}
placeholder="Description (optionnel)"
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Priorité
</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Statut
</label>
<select
name="status"
defaultValue={task?.status || "pending"}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
>
<option value="pending">⏳ En cours</option>
<option value="completed">✅ Terminée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
📅 Date de début
</label>
<input
name="startDate"
type="date"
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
⏰ Date d'échéance
</label>
<input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
Assigné à *
</label>
<input
name="assignedTo"
type="text"
defaultValue={task?.assignedTo || "admin"}
placeholder="Nom de la personne"
required
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
Annuler
</button>
<button
type="submit"
style={{
padding: '6px 12px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
{task ? "Modifier" : "Créer"}
</button>
</div>
</form>
</td>
</tr>
);
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Chargement des tâches...</p>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Header */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
📋 Gestion des Tâches
</h1>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
padding: '8px 16px',
backgroundColor: showCreateForm ? '#dc2626' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
{showCreateForm ? '❌ Annuler' : '➕ Nouvelle Tâche'}
</button>
</div>
{/* Filtres */}
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<input
type="text"
placeholder="🔍 Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">Tous les statuts</option>
<option value="pending">En cours</option>
<option value="completed">Terminées</option>
</select>
</div>
</div>
</div>
{/* Tableau des tâches */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ backgroundColor: '#f9fafb' }}>
<tr>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Titre</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Priorité</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Statut</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Assigné à</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Dates</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: '600', color: '#6b7280', borderBottom: '1px solid #e5e7eb' }}>Actions</th>
</tr>
</thead>
<tbody>
{showCreateForm && (
<TaskForm
onSubmit={createTask}
onCancel={() => setShowCreateForm(false)}
/>
)}
{filteredTasks.length === 0 ? (
<tr>
<td colSpan={6} style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
Aucune tâche trouvée
</td>
</tr>
) : (
filteredTasks.map((task) => (
editingId === task.id ? (
<TaskForm
key={task.id}
task={task}
onSubmit={(formData) => updateTask(task.id, formData)}
onCancel={() => setEditingId(null)}
/>
) : (
<tr key={task.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={{ padding: '12px 16px' }}>
<div>
<div style={{
fontWeight: '500',
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
}}>
{task.title}
</div>
{task.description && (
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
{task.description}
</div>
)}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '500',
backgroundColor: task.priority === 'high' ? '#fee2e2' : task.priority === 'medium' ? '#fef3c7' : '#d1fae5',
color: task.priority === 'high' ? '#dc2626' : task.priority === 'medium' ? '#d97706' : '#059669'
}}>
{task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '500',
backgroundColor: task.status === 'completed' ? '#d1fae5' : '#fef3c7',
color: task.status === 'completed' ? '#059669' : '#d97706'
}}>
{task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#6b7280' }}>
{task.assignedTo}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: '#6b7280' }}>
{task.startDate && (
<div>📅 {new Date(task.startDate).toLocaleDateString('fr-FR')}</div>
)}
{task.dueDate && (
<div>⏰ {new Date(task.dueDate).toLocaleDateString('fr-FR')}</div>
)}
</td>
<td style={{ padding: '12px 16px' }}>
<button
onClick={() => setEditingId(task.id)}
style={{
padding: '4px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
✏️ Modifier
</button>
</td>
</tr>
)
))
)}
</tbody>
</table>
</div>
</div>
);
}
-546
View File
@@ -1,546 +0,0 @@
import { useState, useEffect } from "react";
// Version 100% autonome sans aucune dépendance externe
export default function TasksProductionSimple() {
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTask, setSelectedTask] = useState<any>(null);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
// Charger les tâches
const loadTasks = async () => {
try {
const response = await fetch('/api/tasks');
if (response.ok) {
const data = await response.json();
setTasks(data);
}
} catch (error) {
console.error('Erreur lors du chargement des tâches:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, []);
// Filtrer les tâches
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.title?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Composant formulaire inline
const TaskForm = ({ task, onClose }: any) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const priority = formData.get('priority') as string;
const status = formData.get('status') as string;
const assignedTo = formData.get('assignedTo') as string;
const startDate = formData.get('startDate') as string;
const dueDate = formData.get('dueDate') as string;
if (!title?.trim()) {
alert("Le titre est requis");
return;
}
if (!assignedTo?.trim()) {
alert("L'assignation est requise");
return;
}
const submitBtn = e.currentTarget.querySelector('button[type="submit"]') as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "...";
try {
const taskData = {
title: title.trim(),
description: description?.trim() || "",
priority: priority || "medium",
status: status || "pending",
assignedTo: assignedTo.trim(),
startDate: startDate || null,
dueDate: dueDate || null,
};
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
const method = task ? 'PUT' : 'POST';
if (!task) {
(taskData as any).groupId = 1;
(taskData as any).createdBy = 'admin';
}
console.log('📤 Sending request:', { url, method, taskData });
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
console.log('📥 Response:', { status: response.status, ok: response.ok });
if (!response.ok) {
const errorText = await response.text();
console.error('❌ API Error:', { status: response.status, error: errorText });
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
alert(task ? "Tâche modifiée avec succès" : "Tâche créée avec succès");
onClose();
loadTasks(); // Recharger les tâches
} catch (error) {
console.error('Erreur:', error);
alert("Erreur lors de l'opération");
submitBtn.disabled = false;
submitBtn.textContent = task ? "Modifier" : "Créer";
}
};
return (
<div style={{ padding: '24px', backgroundColor: 'white' }}>
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '16px', marginBottom: '24px' }}>
<p style={{ fontSize: '14px', color: '#6b7280', margin: '0' }}>
📅 Date de début = Quand la tâche devient visible<br/>
⏰ Date d'échéance = Quand la tâche doit être terminée
</p>
<button
type="button"
onClick={onClose}
style={{
float: 'right',
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
backgroundColor: 'white',
cursor: 'pointer'
}}
>
Annuler
</button>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Titre *
</label>
<input
name="title"
type="text"
defaultValue={task?.title || ""}
placeholder="Titre de la tâche"
required
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Description
</label>
<textarea
name="description"
defaultValue={task?.description || ""}
placeholder="Description de la tâche (optionnel)"
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
resize: 'vertical'
}}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Priorité *
</label>
<select
name="priority"
defaultValue={task?.priority || "medium"}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="low">🟢 Faible</option>
<option value="medium">🟡 Moyenne</option>
<option value="high">🔴 Élevée</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Statut
</label>
<select
name="status"
defaultValue={task?.status || "pending"}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="pending">⏳ En cours</option>
<option value="completed">✅ Terminée</option>
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
📅 Date de début <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
</label>
<input
name="startDate"
type="date"
defaultValue={task?.startDate ? new Date(task.startDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
⏰ Date d'échéance <span style={{ fontSize: '12px', color: '#6b7280' }}>(optionnel)</span>
</label>
<input
name="dueDate"
type="date"
defaultValue={task?.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : ""}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Assigné à *
</label>
<input
name="assignedTo"
type="text"
defaultValue={task?.assignedTo || "admin"}
placeholder="Nom de la personne assignée"
required
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '24px' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
backgroundColor: 'white',
cursor: 'pointer'
}}
>
Annuler
</button>
<button
type="submit"
style={{
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: 'pointer'
}}
>
{task ? "Modifier" : "Créer"}
</button>
</div>
</form>
</div>
);
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p>Chargement des tâches...</p>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* Header */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '24px',
marginBottom: '24px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', margin: '0', color: '#1f2937' }}>
📋 Gestion des Tâches
</h1>
<button
onClick={() => setShowCreateModal(true)}
style={{
padding: '10px 20px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
➕ Nouvelle Tâche
</button>
</div>
{/* Filtres */}
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<input
type="text"
placeholder="🔍 Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minWidth: '150px'
}}
>
<option value="all">Tous les statuts</option>
<option value="pending">En cours</option>
<option value="completed">Terminées</option>
</select>
</div>
</div>
</div>
{/* Liste des tâches */}
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
{filteredTasks.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: '#6b7280' }}>
<p>Aucune tâche trouvée</p>
</div>
) : (
filteredTasks.map((task, index) => (
<div
key={task.id}
style={{
padding: '16px 24px',
borderBottom: index < filteredTasks.length - 1 ? '1px solid #e5e7eb' : 'none',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '16px',
fontWeight: '500',
color: task.status === 'completed' ? '#6b7280' : '#1f2937',
textDecoration: task.status === 'completed' ? 'line-through' : 'none'
}}>
{task.title}
</h3>
<div style={{ display: 'flex', gap: '16px', fontSize: '14px', color: '#6b7280' }}>
<span>
Priorité: {task.priority === 'high' ? '🔴 Élevée' : task.priority === 'medium' ? '🟡 Moyenne' : '🟢 Faible'}
</span>
<span>
Statut: {task.status === 'completed' ? '✅ Terminée' : '⏳ En cours'}
</span>
<span>Assigné à: {task.assignedTo}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => {
setSelectedTask(task);
setShowEditModal(true);
}}
style={{
padding: '6px 12px',
border: '1px solid #d1d5db',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
fontSize: '12px'
}}
>
✏️ Modifier
</button>
</div>
</div>
))
)}
</div>
{/* Modal de création */}
{showCreateModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}>
<div style={{
padding: '24px 24px 16px',
borderBottom: '1px solid #e5e7eb'
}}>
<h2 style={{
fontSize: '18px',
fontWeight: '600',
margin: '0'
}}>
Créer une nouvelle tâche
</h2>
</div>
<TaskForm onClose={() => setShowCreateModal(false)} />
</div>
</div>
)}
{/* Modal d'édition */}
{showEditModal && selectedTask && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '600px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}}>
<div style={{
padding: '24px 24px 16px',
borderBottom: '1px solid #e5e7eb'
}}>
<h2 style={{
fontSize: '18px',
fontWeight: '600',
margin: '0'
}}>
Modifier la tâche
</h2>
</div>
<TaskForm
task={selectedTask}
onClose={() => {
setShowEditModal(false);
setSelectedTask(null);
}}
/>
</div>
</div>
)}
</div>
);
}
-504
View File
@@ -1,504 +0,0 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/contexts/StoreContext";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
ListTodo,
Plus,
Search,
Circle,
Clock,
AlertTriangle,
CheckCircle,
Edit,
Trash2
} from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Task } from "@shared/schema";
type TaskWithRelations = Task & {
creator: { id: string; username: string; name: string; };
group: { id: number; name: string; color: string; };
};
export default function TasksSimplified() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const { toast } = useToast();
const queryClient = useQueryClient();
// États locaux
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [priorityFilter, setPriorityFilter] = useState<string>("all");
// Modals et tâche sélectionnée
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
// Récupération des tâches
const { data: tasks = [], isLoading } = useQuery({
queryKey: ["/api/tasks", selectedStoreId],
queryFn: () => {
const params = new URLSearchParams();
if (selectedStoreId) {
params.append('storeId', selectedStoreId.toString());
}
return fetch(`/api/tasks?${params.toString()}`).then(res => res.json());
},
enabled: !!user,
});
// Récupération des magasins
const { data: stores = [] } = useQuery<any[]>({
queryKey: ["/api/groups"],
enabled: !!user,
});
// Filtrer les tâches
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.assignedTo?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
// Création d'une tâche
const handleCreateTask = async (taskData: any) => {
try {
const finalData = {
...taskData,
groupId: selectedStoreId ? parseInt(selectedStoreId) : stores[0]?.id
};
console.log('🔍 TasksSimplified - Creating task with data:', {
selectedStoreId,
storesLength: stores.length,
finalGroupId: finalData.groupId,
taskData: finalData
});
await apiRequest('/api/tasks', 'POST', finalData);
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setShowCreateModal(false);
toast({
title: "Succès",
description: "Tâche créée avec succès",
});
} catch (error) {
console.error('❌ TasksSimplified - Error creating task:', error);
toast({
title: "Erreur",
description: "Impossible de créer la tâche",
variant: "destructive",
});
}
};
// Modification d'une tâche
const handleUpdateTask = async (taskData: any) => {
if (!selectedTask) return;
try {
await apiRequest(`/api/tasks/${selectedTask.id}`, 'PUT', taskData);
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
setShowEditModal(false);
setSelectedTask(null);
toast({
title: "Succès",
description: "Tâche modifiée avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de modifier la tâche",
variant: "destructive",
});
}
};
// Complétion d'une tâche
const handleCompleteTask = async (taskId: number) => {
try {
await apiRequest(`/api/tasks/${taskId}/complete`, 'POST');
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Succès",
description: "Tâche marquée comme terminée",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de compléter la tâche",
variant: "destructive",
});
}
};
// Suppression d'une tâche
const handleDeleteTask = async (taskId: number) => {
try {
await apiRequest(`/api/tasks/${taskId}`, 'DELETE');
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
toast({
title: "Succès",
description: "Tâche supprimée avec succès",
});
} catch (error) {
toast({
title: "Erreur",
description: "Impossible de supprimer la tâche",
variant: "destructive",
});
}
};
const handleEditTask = (task: TaskWithRelations) => {
setSelectedTask(task);
setShowEditModal(true);
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed": return "bg-green-100 text-green-800";
case "pending": return "bg-yellow-100 text-yellow-800";
default: return "bg-gray-100 text-gray-800";
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case "high": return "bg-red-100 text-red-800";
case "medium": return "bg-blue-100 text-blue-800";
case "low": return "bg-green-100 text-green-800";
default: return "bg-gray-100 text-gray-800";
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Chargement des tâches...</div>
</div>
);
}
if (!user) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Vous devez être connecté pour accéder aux tâches.</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<ListTodo className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Gestion des Tâches</h1>
</div>
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Tâche
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
</DialogHeader>
<TaskForm onSubmit={handleCreateTask} />
</DialogContent>
</Dialog>
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filtres</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Rechercher une tâche..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="min-w-[150px]">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Statut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="pending">En attente</SelectItem>
<SelectItem value="completed">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-[150px]">
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue placeholder="Priorité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes priorités</SelectItem>
<SelectItem value="high">Haute</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="low">Basse</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Liste des tâches */}
<div className="space-y-4">
{filteredTasks.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center h-32">
<div className="text-center">
<ListTodo className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Aucune tâche trouvée</p>
</div>
</CardContent>
</Card>
) : (
filteredTasks.map((task: TaskWithRelations) => (
<Card key={task.id} className={`hover:shadow-md transition-shadow ${task.status === 'completed' ? 'opacity-60 bg-gray-50' : ''}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className={`font-semibold text-lg ${task.status === 'completed' ? 'line-through text-gray-500' : ''}`}>{task.title}</h3>
<Badge className={getStatusColor(task.status)}>
{task.status === "completed" ? "Terminée" : "En attente"}
</Badge>
<Badge className={getPriorityColor(task.priority)}>
{task.priority === "high" ? "Haute" : task.priority === "medium" ? "Moyenne" : "Basse"}
</Badge>
</div>
{task.description && (
<p className={`mb-3 ${task.status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-600'}`}>{task.description}</p>
)}
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<span className="font-medium">Assigné à:</span>
<span>{task.assignedTo || "Non assigné"}</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Magasin:</span>
<span className="px-2 py-1 rounded text-xs" style={{ backgroundColor: task.group.color + '20', color: task.group.color }}>
{task.group.name}
</span>
</div>
<div className="flex items-center space-x-1">
<span className="font-medium">Créé par:</span>
<span>{task.creator.name || task.creator.username}</span>
</div>
{task.dueDate && (
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>
Échéance: {format(new Date(task.dueDate), 'dd/MM/yyyy HH:mm', { locale: fr })}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
{task.status !== "completed" && (
<Button
size="sm"
variant="outline"
onClick={() => handleCompleteTask(task.id)}
className="text-green-600 hover:text-green-700"
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handleEditTask(task)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteTask(task.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Modal de modification */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier la tâche</DialogTitle>
</DialogHeader>
{selectedTask && (
<TaskForm
initialData={selectedTask}
onSubmit={handleUpdateTask}
isEditing={true}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
// Composant de formulaire simplifié
function TaskForm({ onSubmit, initialData, isEditing = false }: {
onSubmit: (data: any) => void;
initialData?: TaskWithRelations;
isEditing?: boolean;
}) {
const [title, setTitle] = useState(initialData?.title || "");
const [description, setDescription] = useState(initialData?.description || "");
const [assignedTo, setAssignedTo] = useState(initialData?.assignedTo || "");
const [priority, setPriority] = useState(initialData?.priority || "medium");
const [dueDate, setDueDate] = useState(
initialData?.dueDate ? format(new Date(initialData.dueDate), 'yyyy-MM-dd') : ""
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
return;
}
const taskData = {
title: title.trim(),
description: description.trim() || undefined,
assignedTo: assignedTo?.trim() || "Non assigné",
priority,
startDate: null, // Toujours inclure startDate même si null
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
};
onSubmit(taskData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Titre de la tâche *
</label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de la tâche"
className="w-full p-2 border rounded-md"
rows={3}
/>
</div>
<div>
<label htmlFor="assignedTo" className="block text-sm font-medium mb-1">
Assigné à <span className="text-xs text-gray-500">(optionnel)</span>
</label>
<Input
id="assignedTo"
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
placeholder="Nom de la personne responsable"
/>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Priorité
</label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Basse</SelectItem>
<SelectItem value="medium">Moyenne</SelectItem>
<SelectItem value="high">Haute</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
Date d'échéance
</label>
<Input
id="dueDate"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="flex justify-end space-x-2">
<Button type="submit">
{isEditing ? "Modifier" : "Créer"}
</Button>
</div>
</form>
);
}
+3 -3
View File
@@ -737,7 +737,7 @@ export default function UsersPage() {
<div className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: userGroup.group.color }}
style={{ backgroundColor: userGroup.group.color || undefined }}
/>
{userGroup.group.name}
</div>
@@ -901,7 +901,7 @@ export default function UsersPage() {
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
style={{ backgroundColor: group.color || undefined }}
/>
<span className="text-sm">{group.name}</span>
</div>
@@ -1063,7 +1063,7 @@ export default function UsersPage() {
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
style={{ backgroundColor: group.color || undefined }}
/>
<Label htmlFor={`group-${group.id}`} className="text-sm">
{group.name}
@@ -23,8 +23,7 @@ import {
X,
Filter,
Loader2,
MessageSquare,
Search
MessageSquare
} from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
@@ -553,7 +552,7 @@ export default function MobileCustomerOrdersPage() {
<FormField
control={form.control}
name="isPromotionalPrice"
name={"isPromotionalPrice" as any}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
@@ -573,7 +572,7 @@ export default function MobileCustomerOrdersPage() {
<FormField
control={form.control}
name="customerNotified"
name={"customerNotified" as any}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
-5
View File
@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1758639574 connect.sid s%3A8_9iOHdXBiypBHfh9CFiGGT7-mgFDQ-9.MPmF2NrBG7ywTs2h9HX4yW1JVNQ5dbq%2FHoZpz8jRTpo
-5
View File
@@ -1,5 +0,0 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1755861696 connect.sid s%3AI3ww2VSYnJCTbvhTLFfsKWucSCgLEXnH.Q%2FZB%2FqjpNIGDgcSjAp4O%2BHP03Qrmj71qIb8njdhnrK4
-43
View File
@@ -1,43 +0,0 @@
-- Script DEFINITIF pour créer la table webhook_bap_config en production
-- À exécuter directement sur votre PostgreSQL de production
-- ATTENTION : Remplacez YOUR_DATABASE par le nom de votre base
\c YOUR_DATABASE;
-- Créer la table webhook_bap_config
DROP TABLE IF EXISTS webhook_bap_config CASCADE;
CREATE TABLE webhook_bap_config (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
webhook_url TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insérer la configuration par défaut avec VOTRE URL
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) VALUES (
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook-test/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration pour envoi des fichiers BAP vers n8n',
true
);
-- Créer la table migrations si elle n'existe pas
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Marquer la migration comme appliquée
INSERT INTO migrations (filename) VALUES ('20250903141000_create_webhook_bap_config.sql')
ON CONFLICT (filename) DO NOTHING;
-- Vérifier que tout est créé
SELECT 'Table webhook_bap_config créée avec succès' as message;
SELECT * FROM webhook_bap_config;
SELECT 'Table migrations mise à jour' as message;
SELECT * FROM migrations WHERE filename LIKE '%webhook_bap%';
-26
View File
@@ -1,26 +0,0 @@
-- Script SQL simple pour créer UNIQUEMENT la table announcements en production
-- À exécuter directement dans le conteneur Docker
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
author_id VARCHAR(255) NOT NULL,
group_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT announcements_priority_check CHECK (priority IN ('normal', 'important', 'urgent')),
CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
-- Créer les index pour les performances
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Vérification
SELECT 'Table announcements créée avec succès' as resultat;
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'announcements' ORDER BY ordinal_position;
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env node
// Script de débogage pour tester les endpoints de production
import https from 'https';
const BASE_URL = 'https://logiflow.ffnancy.fr';
async function makeRequest(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const requestOptions = {
hostname: url.hostname,
path: url.pathname + url.search,
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Debug-Script/1.0',
...options.headers
}
};
const req = https.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
resolve({
status: res.statusCode,
headers: res.headers,
data: data,
length: data.length
});
});
});
req.on('error', (error) => {
reject(error);
});
if (options.body) {
req.write(options.body);
}
req.end();
});
}
async function debugProduction() {
console.log('🔍 Testing production endpoints...\n');
try {
// Test 1: Health check
console.log('1. Health check:');
const health = await makeRequest('/api/health');
console.log(` Status: ${health.status}`);
console.log(` Response: ${health.data.substring(0, 100)}...\n`);
// Test 2: Default credentials check
console.log('2. Default credentials check:');
const defaultCreds = await makeRequest('/api/default-credentials-check');
console.log(` Status: ${defaultCreds.status}`);
console.log(` Response: ${defaultCreds.data}\n`);
// Test 3: Login attempts with different credentials
const credentialsList = [
{ username: 'admin', password: 'admin' },
{ username: 'administrator', password: 'administrator' },
{ username: 'lff', password: 'lff2024' },
{ username: 'logiflow', password: 'logiflow' },
{ username: 'root', password: 'root' }
];
let login = null;
let cookies = '';
for (const creds of credentialsList) {
console.log(`3. Login attempt with ${creds.username}:${creds.password}:`);
login = await makeRequest('/api/login', {
method: 'POST',
body: JSON.stringify(creds)
});
console.log(` Status: ${login.status}`);
console.log(` Response: ${login.data.substring(0, 200)}...\n`);
if (login.status === 200 && login.headers['set-cookie']) {
cookies = login.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
console.log(`✅ Successful login with ${creds.username}\n`);
break;
}
}
// Cookies already extracted above if login was successful
// Test 4: User info (with cookies)
if (cookies) {
console.log('4. User info (authenticated):');
const user = await makeRequest('/api/user', {
headers: { 'Cookie': cookies }
});
console.log(` Status: ${user.status}`);
console.log(` Response: ${user.data.substring(0, 200)}...\n`);
// Test 5: Tasks (with cookies)
console.log('5. Tasks list (authenticated):');
const tasks = await makeRequest('/api/tasks', {
headers: { 'Cookie': cookies }
});
console.log(` Status: ${tasks.status}`);
console.log(` Response: ${tasks.data.substring(0, 300)}...\n`);
// Test 6: Test task update (with cookies) - testing the problematic endpoint
console.log('6. Task update test (problematic endpoint):');
const taskUpdate = await makeRequest('/api/tasks/99', {
method: 'PUT',
headers: { 'Cookie': cookies },
body: JSON.stringify({
title: 'Test Debug',
description: 'Test de debug',
priority: 'medium',
status: 'pending',
assignedTo: 'admin',
startDate: '2025-08-20',
dueDate: null
})
});
console.log(` Status: ${taskUpdate.status}`);
console.log(` Response: ${taskUpdate.data}\n`);
} else {
console.log('❌ No cookies received from login - cannot test authenticated endpoints\n');
}
} catch (error) {
console.error('❌ Error during debug:', error.message);
}
}
debugProduction();
-77
View File
@@ -1,77 +0,0 @@
-- 🔍 DIAGNOSTIC PRODUCTION COMPLET - Module Avoirs
-- Copiez-collez ce script dans votre utilitaire SQL pour diagnostic précis
BEGIN;
-- SECTION 1: STRUCTURE DES TABLES
SELECT 'VÉRIFICATION STRUCTURE TABLES' AS section_title;
-- Table avoirs - structure exacte
SELECT 'STRUCTURE_AVOIRS' AS check_type, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'avoirs' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table suppliers - structure exacte
SELECT 'STRUCTURE_SUPPLIERS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'suppliers' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table groups - structure exacte
SELECT 'STRUCTURE_GROUPS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'groups' AND table_schema = 'public'
ORDER BY ordinal_position;
-- Table users - structure exacte
SELECT 'STRUCTURE_USERS' AS check_type, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users' AND table_schema = 'public'
ORDER BY ordinal_position;
-- SECTION 2: CONTRAINTES
SELECT 'CONTRAINTES_AVOIRS' AS check_type, conname AS constraint_name, contype AS type,
confrelid::regclass AS referenced_table
FROM pg_constraint
WHERE conrelid = 'avoirs'::regclass;
-- SECTION 3: DONNÉES EXISTANTES
SELECT 'COMPTES_TABLES' AS check_type, 'suppliers' AS table_name, COUNT(*) AS count FROM suppliers
UNION ALL
SELECT 'COMPTES_TABLES', 'groups', COUNT(*) FROM groups
UNION ALL
SELECT 'COMPTES_TABLES', 'users', COUNT(*) FROM users
UNION ALL
SELECT 'COMPTES_TABLES', 'avoirs', COUNT(*) FROM avoirs;
-- SECTION 4: EXEMPLES DE DONNÉES (si elles existent)
-- Suppliers
SELECT 'EXEMPLES_SUPPLIERS' AS check_type, id, name FROM suppliers LIMIT 3;
-- Groups
SELECT 'EXEMPLES_GROUPS' AS check_type, id, name FROM groups LIMIT 3;
-- Users
SELECT 'EXEMPLES_USERS' AS check_type, id,
COALESCE(username, 'no_username') AS username,
role
FROM users LIMIT 3;
-- SECTION 5: TEST DE COMPATIBILITÉ
-- Test si on peut insérer avec des champs NULL
SELECT 'TEST_COMPATIBILITÉ' AS check_type,
CASE
WHEN (SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'avoirs' AND column_name = 'invoice_reference') = 'YES'
THEN 'invoice_reference_NULLABLE'
ELSE 'invoice_reference_NOT_NULL'
END AS invoice_ref_status,
CASE
WHEN (SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'avoirs' AND column_name = 'amount') = 'YES'
THEN 'amount_NULLABLE'
ELSE 'amount_NOT_NULL'
END AS amount_status;
ROLLBACK; -- Ne pas sauvegarder, c'est juste du diagnostic
-42
View File
@@ -1,42 +0,0 @@
-- Commandes à exécuter DIRECTEMENT dans votre conteneur Docker
-- Copiez et collez ces commandes une par une dans votre terminal
-- 1. Entrer dans le conteneur
-- docker exec -it logiflow-logiflow-1 /bin/bash
-- 2. Une fois dans le conteneur, exécuter cette commande SQL :
-- psql $DATABASE_URL
-- 3. Coller ce SQL dans psql :
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
author_id VARCHAR(255) NOT NULL,
group_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
ALTER TABLE announcements ADD CONSTRAINT announcements_priority_check
CHECK (priority IN ('normal', 'important', 'urgent'));
ALTER TABLE announcements ADD CONSTRAINT announcements_author_id_fkey
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE announcements ADD CONSTRAINT announcements_group_id_fkey
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Vérification
SELECT 'SUCCESS: Table announcements créée avec succès!' as result;
SELECT COUNT(*) as nb_colonnes FROM information_schema.columns WHERE table_name = 'announcements';
-- 4. Taper \q pour quitter psql
-- 5. Taper exit pour quitter le conteneur
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Script de correction pour Docker entrypoint
# Applique la migration directement via docker-compose exec
set -e
echo "🔧 [FIX] Correction du problème docker-entrypoint.sh"
# Vérifier que le conteneur fonctionne
if ! docker-compose ps | grep -q logiflow; then
echo "❌ [FIX] Conteneur logiflow non trouvé, démarrage..."
docker-compose up -d logiflow-db
sleep 10
fi
echo "🔄 [FIX] Application de la migration DLC directement..."
# Appliquer la migration via exec
docker-compose exec -T logiflow-db psql -U logiflow_admin -d logiflow_db << 'EOF'
-- Migration DLC stock épuisé
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
-- Index pour performance
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
-- Vérification
SELECT 'Migration DLC appliquée' as status;
EOF
echo "✅ [FIX] Migration DLC appliquée avec succès"
# Redémarrer le conteneur application
echo "🔄 [FIX] Redémarrage du conteneur application..."
docker-compose restart logiflow
echo "✅ [FIX] Correction terminée"
-34
View File
@@ -1,34 +0,0 @@
-- Script pour créer la table webhook_bap_config en production
-- À exécuter directement sur votre base PostgreSQL
-- Créer la table
CREATE TABLE IF NOT EXISTS webhook_bap_config (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
webhook_url TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insérer la configuration par défaut
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
VALUES (
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook-test/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration par défaut pour l''envoi des fichiers BAP vers n8n',
true
) ON CONFLICT DO NOTHING;
-- Créer la table de suivi des migrations
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Marquer la migration comme appliquée
INSERT INTO migrations (filename)
VALUES ('20250903141000_create_webhook_bap_config.sql')
ON CONFLICT DO NOTHING;
-38
View File
@@ -1,38 +0,0 @@
-- Script de correction production pour créer la table announcements
-- À exécuter dans votre conteneur Docker
BEGIN;
-- Création de la table announcements
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')),
author_id VARCHAR(255) NOT NULL,
group_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
-- Création des index
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Commentaires
COMMENT ON TABLE announcements IS 'Admin-managed announcements/information system';
COMMENT ON COLUMN announcements.priority IS 'Priority level: normal, important, urgent';
COMMENT ON COLUMN announcements.group_id IS 'NULL for global announcements, specific group_id for store-specific announcements';
-- Vérification
SELECT
'Table announcements créée avec succès!' as message,
COUNT(*) as nb_colonnes
FROM information_schema.columns
WHERE table_name = 'announcements';
COMMIT;
+32
View File
@@ -46,6 +46,19 @@ CREATE TABLE IF NOT EXISTS "groups" (
"nocodb_supplier_column_name" varchar,
"nocodb_due_date_column_name" varchar,
"webhook_url" varchar(500),
-- Coordonnées du magasin (signature des mails fournisseurs)
"address" text,
"phone" varchar(50),
"logo" text,
-- Configuration SMTP propre au magasin
"smtp_enabled" boolean DEFAULT false,
"smtp_host" varchar(255),
"smtp_port" integer,
"smtp_secure" boolean DEFAULT false,
"smtp_user" varchar(255),
"smtp_password" varchar(255),
"smtp_sender_email" varchar(255),
"smtp_sender_name" varchar(255),
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP
);
@@ -406,6 +419,25 @@ CREATE TABLE IF NOT EXISTS "webhook_bap_config" (
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP
);
-- Historique des mails de relance fournisseurs (rapprochement BL/Factures)
CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
"id" serial PRIMARY KEY NOT NULL,
"delivery_id" integer NOT NULL,
"group_id" integer NOT NULL,
"supplier_id" integer,
"supplier_name" varchar(255),
"sent_to" varchar(255) NOT NULL,
"subject" text,
"status" varchar(20) NOT NULL,
"error_message" text,
"message_id" varchar(255),
"sent_by" varchar NOT NULL,
"sent_by_name" varchar(255),
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
-- ============================================================================
-- FOREIGN KEY CONSTRAINTS
-- ============================================================================
-25
View File
@@ -1,25 +0,0 @@
-- Script d'initialisation PostgreSQL pour les annonces
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')),
author_id VARCHAR(255) NOT NULL,
group_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index pour améliorer les performances
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Insertion de données de test
INSERT INTO announcements (title, content, priority, author_id, group_id) VALUES
('Bienvenue dans le système d''informations', 'Ce nouveau système remplace l''ancienne carte des commandes clients. Vous pouvez maintenant créer et gérer des annonces importantes.', 'important', 'admin', 1),
('Maintenance programmée', 'Une maintenance système aura lieu dimanche prochain de 2h à 4h du matin.', 'normal', 'admin', NULL)
ON CONFLICT DO NOTHING;
-- Afficher le résultat
SELECT COUNT(*) as total_announcements FROM announcements;
+94 -97
View File
@@ -78,6 +78,7 @@
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-fetch": "^3.3.2",
"nodemailer": "^9.0.5",
"openid-client": "^6.7.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
@@ -106,11 +107,11 @@
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/connect-pg-simple": "^7.0.3",
"@types/date-fns": "^2.5.3",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^20.16.11",
"@types/node-fetch": "^2.6.13",
"@types/nodemailer": "^8.0.1",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.27",
@@ -3735,13 +3736,6 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/date-fns": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz",
"integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3839,6 +3833,16 @@
"form-data": "^4.0.4"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@@ -4153,9 +4157,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -4166,7 +4170,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -4233,9 +4237,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -5831,14 +5835,14 @@
"license": "MIT"
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -5857,7 +5861,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -6095,16 +6099,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -6395,9 +6399,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -6880,9 +6884,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.castarray": {
@@ -7096,12 +7100,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -7110,15 +7114,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -7192,33 +7187,22 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/multer/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mz": {
@@ -7233,9 +7217,9 @@
}
},
"node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
@@ -7349,6 +7333,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7537,9 +7530,9 @@
"license": "ISC"
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pause": {
@@ -7652,9 +7645,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -7682,9 +7675,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@@ -7701,7 +7694,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -7839,9 +7832,9 @@
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -7938,12 +7931,13 @@
"license": "ISC"
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -8564,14 +8558,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -8583,13 +8577,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -9928,9 +9922,9 @@
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -9986,15 +9980,18 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
+2 -1
View File
@@ -80,6 +80,7 @@
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-fetch": "^3.3.2",
"nodemailer": "^9.0.5",
"openid-client": "^6.7.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
@@ -108,11 +109,11 @@
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/connect-pg-simple": "^7.0.3",
"@types/date-fns": "^2.5.3",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^20.16.11",
"@types/node-fetch": "^2.6.13",
"@types/nodemailer": "^8.0.1",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.27",
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env node
/**
* Production deployment script
* Runs database migrations and starts the application
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
async function deployToProduction() {
console.log('🚀 Starting production deployment...');
try {
// Check if we're in production environment
if (process.env.NODE_ENV !== 'production') {
console.log('⚠️ Warning: NODE_ENV is not set to production');
}
// Check if DATABASE_URL is available
if (!process.env.DATABASE_URL) {
console.error('❌ DATABASE_URL environment variable is required for production');
process.exit(1);
}
console.log('✅ Environment variables verified');
// Run database migrations
console.log('🔄 Running database migrations...');
const migrateScriptPath = path.join(__dirname, 'migrations', 'migrate.js');
if (fs.existsSync(migrateScriptPath)) {
execSync(`node ${migrateScriptPath}`, { stdio: 'inherit' });
console.log('✅ Database migrations completed');
} else {
console.log('⚠️ No migration script found, skipping migrations');
}
// Start the application
console.log('🚀 Starting application...');
execSync('npm start', { stdio: 'inherit' });
} catch (error) {
console.error('❌ Deployment failed:', error.message);
process.exit(1);
}
}
// Execute if this script is run directly
if (require.main === module) {
deployToProduction();
}
module.exports = { deployToProduction };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

-33
View File
@@ -1,33 +0,0 @@
import fetch from 'node-fetch';
import FormData from 'form-data';
const webhookUrl = 'https://workflow.ffnancy.fr/webhook-test/acf9cbf7-040a-4cf5-a43d-80210420d30a';
async function testWebhook() {
console.log('🚀 Testing webhook:', webhookUrl);
const formData = new FormData();
formData.append('file', Buffer.from('test content'), {
filename: 'test.txt',
contentType: 'text/plain'
});
formData.append('supplier', 'Test Supplier');
formData.append('blNumber', '12345');
formData.append('type', 'Facture');
try {
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData,
headers: formData.getHeaders()
});
console.log('📡 Response status:', response.status);
const text = await response.text();
console.log('📄 Response body:', text);
} catch (error) {
console.error('❌ Error calling webhook:', error);
}
}
testWebhook();
-27
View File
@@ -1,27 +0,0 @@
const webhookUrl = 'https://workflow.ffnancy.fr/webhook-test/acf9cbf7-040a-4cf5-a43d-80210420d30a';
async function testWebhook() {
console.log('🚀 Testing webhook (Native Fetch/FormData):', webhookUrl);
const formData = new FormData();
const fileBlob = new Blob(['test content'], { type: 'text/plain' });
formData.append('file', fileBlob, 'test.txt');
formData.append('supplier', 'Test Supplier');
formData.append('blNumber', '12345');
formData.append('type', 'Facture');
try {
const response = await fetch(webhookUrl, {
method: 'POST',
body: formData
});
console.log('📡 Response status:', response.status);
const text = await response.text();
console.log('📄 Response body:', text);
} catch (error) {
console.error('❌ Error calling webhook:', error);
}
}
testWebhook();
+11 -28
View File
@@ -11,10 +11,10 @@ type AnnouncementWithRelations = DashboardMessageWithRelations;
// Interface commune pour le stockage
interface IAnnouncementStorage {
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null>;
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined>;
createAnnouncement(announcement: InsertAnnouncement): Promise<DashboardMessage>;
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage>;
deleteAnnouncement(id: number): Promise<void>;
deleteAnnouncement(id: number): Promise<boolean>;
}
// Stockage PostgreSQL pour la production
@@ -60,7 +60,7 @@ class AnnouncementDatabaseStorage implements IAnnouncementStorage {
}));
}
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null> {
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
console.log('🔍 [DB] Getting single announcement:', id);
const result = await db
@@ -136,10 +136,12 @@ class AnnouncementDatabaseStorage implements IAnnouncementStorage {
return updated;
}
async deleteAnnouncement(id: number): Promise<void> {
await db
async deleteAnnouncement(id: number): Promise<boolean> {
const deleted = await db
.delete(dashboardMessages)
.where(eq(dashboardMessages.id, id));
.where(eq(dashboardMessages.id, id))
.returning({ id: dashboardMessages.id });
return deleted.length > 0;
}
}
@@ -184,26 +186,6 @@ class AnnouncementMemoryStorage implements IAnnouncementStorage {
this.announcements.set(testAnnouncement2.id, testAnnouncement2);
}
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null> {
const announcement = this.announcements.get(id);
if (!announcement) {
return null;
}
// Ajouter les relations
const users = await this.usersGetter();
const groups = await this.groupsGetter();
const author = users.find(u => u.id === announcement.createdBy || u.username === announcement.createdBy);
const group = announcement.storeId ? groups.find(g => g.id === announcement.storeId) : undefined;
return {
...announcement,
author: author || { id: announcement.createdBy, firstName: 'Utilisateur', lastName: 'Inconnu', username: announcement.createdBy } as User,
group,
};
}
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
const announcements = Array.from(this.announcements.values());
@@ -329,12 +311,13 @@ class AnnouncementMemoryStorage implements IAnnouncementStorage {
return announcementWithRelations;
}
async deleteAnnouncement(id: number): Promise<void> {
async deleteAnnouncement(id: number): Promise<boolean> {
const deleted = this.announcements.delete(id);
if (deleted) {
console.log(`🗑️ Annonce supprimée: ID ${id} (${this.announcements.size}/${this.MAX_ANNOUNCEMENTS} restantes)`);
}
return deleted;
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function invalidateCache(pattern: string): void {
export function setupCompression(app: any) {
app.use((req: Request, res: Response, next: NextFunction) => {
// Activer la compression pour les réponses JSON
if (req.accepts('gzip') && res.getHeader('Content-Type')?.includes('json')) {
if (req.accepts('gzip') && String(res.getHeader('Content-Type') ?? '').includes('json')) {
res.setHeader('Content-Encoding', 'gzip');
}
next();
+76
View File
@@ -0,0 +1,76 @@
import crypto from 'crypto';
/**
* Chiffrement au repos des secrets applicatifs (mot de passe SMTP des
* magasins, jeton API NocoDB).
*
* - AES-256-GCM (chiffrement authentifié : toute altération en base est détectée)
* - Clé dérivée de ENCRYPTION_KEY, avec repli sur SESSION_SECRET pour que la
* production existante fonctionne sans nouvelle variable d'environnement
* - Format stocké : enc:v1:<iv>:<tag>:<données>, tout en base64
* - Les valeurs héritées en clair sont acceptées en lecture (déchiffrement
* transparent) et re-chiffrées par le balayage de démarrage
*
* ATTENTION : changer ENCRYPTION_KEY/SESSION_SECRET après coup rend les
* secrets déjà chiffrés illisibles — il faudrait alors les ressaisir.
*/
const PREFIX = 'enc:v1:';
function getKey(): Buffer {
const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET;
if (!secret) {
// Même repli figé que les sessions : mieux vaut un chiffrement à clé
// faible qu'un stockage en clair, et la production définit toujours
// SESSION_SECRET via docker-compose.
return crypto.createHash('sha256').update('logiflow-fallback-secret-key').digest();
}
return crypto.createHash('sha256').update(secret).digest();
}
export function isEncryptedSecret(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(PREFIX);
}
/**
* Chiffre un secret. Les valeurs vides et les valeurs déjà chiffrées sont
* renvoyées telles quelles (idempotent : pas de double chiffrement possible).
*/
export function encryptSecret(value: string | null | undefined): string | null | undefined {
if (!value || isEncryptedSecret(value)) {
return value;
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
/**
* Déchiffre un secret. Une valeur non chiffrée (héritage d'avant le
* chiffrement) est renvoyée telle quelle.
*/
export function decryptSecret(value: string | null | undefined): string | null | undefined {
if (!value || !isEncryptedSecret(value)) {
return value;
}
const parts = value.slice(PREFIX.length).split(':');
if (parts.length !== 3) {
throw new Error('Secret chiffré illisible : format inattendu');
}
try {
const [iv, tag, data] = parts.map(p => Buffer.from(p, 'base64'));
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
} catch {
throw new Error(
'Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur.'
);
}
}
+3 -3
View File
@@ -46,9 +46,9 @@ pool.on('error', (err) => {
} catch (error) {
console.error('❌ DB: Database migrations failed with error:', error);
console.error('❌ DB: Migration failure details:', {
name: error?.name,
message: error?.message,
code: error?.code
name: (error as any)?.name,
message: (error as any)?.message,
code: (error as any)?.code
});
// Don't fail the application startup for migration errors
}
+169
View File
@@ -0,0 +1,169 @@
import nodemailer, { type Transporter } from 'nodemailer';
import { decryptSecret } from './crypto';
import {
buildSupplierMailSubject,
buildSupplierMailText,
buildSupplierMailHtml,
type SupplierMailDelivery,
} from '@shared/supplierMail';
/**
* Envoi des mails fournisseurs via la configuration SMTP propre à chaque magasin.
* Chaque magasin renseigne son serveur, ses identifiants et son adresse
* d'expédition sur sa fiche : aucun compte global n'est utilisé.
*/
export interface StoreSmtpConfig {
id?: number;
name?: string | null;
address?: string | null;
phone?: string | null;
logo?: string | null;
smtpEnabled?: boolean | null;
smtpHost?: string | null;
smtpPort?: number | null;
smtpSecure?: boolean | null;
smtpUser?: string | null;
smtpPassword?: string | null;
smtpSenderEmail?: string | null;
smtpSenderName?: string | null;
}
export class SmtpConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'SmtpConfigError';
}
}
/**
* Vérifie que le magasin dispose d'une configuration exploitable.
* Renvoie la liste des champs manquants plutôt qu'un simple booléen,
* pour pouvoir dire à l'utilisateur ce qu'il reste à renseigner.
*/
export function getMissingSmtpFields(group: StoreSmtpConfig | null | undefined): string[] {
if (!group) return ['magasin'];
const missing: string[] = [];
if (!group.smtpHost?.trim()) missing.push('serveur SMTP');
if (!group.smtpPort) missing.push('port SMTP');
if (!group.smtpSenderEmail?.trim()) missing.push('adresse expéditeur');
return missing;
}
/**
* Construit le transporteur nodemailer d'un magasin.
* L'authentification est optionnelle : certains relais internes acceptent
* les envois sans identifiants.
*/
export function createTransporter(group: StoreSmtpConfig): Transporter {
const missing = getMissingSmtpFields(group);
if (missing.length > 0) {
throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`);
}
// Le mot de passe est stocké chiffré (AES-256-GCM) : déchiffré uniquement
// ici, au moment de la connexion au serveur SMTP
const smtpPassword = decryptSecret(group.smtpPassword);
const hasAuth = Boolean(group.smtpUser?.trim() && smtpPassword);
return nodemailer.createTransport({
host: group.smtpHost!.trim(),
port: Number(group.smtpPort),
secure: Boolean(group.smtpSecure),
auth: hasAuth
? { user: group.smtpUser!.trim(), pass: smtpPassword! }
: undefined,
});
}
/** Adresse "De :" du magasin, avec son nom affiché. */
function buildFromAddress(group: StoreSmtpConfig): string {
const senderEmail = group.smtpSenderEmail!.trim();
const senderName = group.smtpSenderName?.trim() || group.name?.trim();
return senderName ? `"${senderName}" <${senderEmail}>` : senderEmail;
}
/**
* Extrait le contenu binaire d'un logo stocké en data URI.
* Renvoie null si le logo est absent ou dans un format inattendu.
*/
function parseLogoDataUri(logo: string | null | undefined) {
if (!logo) return null;
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(logo.trim());
if (!match) return null;
const [, mimeType, base64] = match;
const extension = mimeType.split('/')[1]?.split('+')[0] || 'png';
try {
return {
content: Buffer.from(base64, 'base64'),
contentType: mimeType,
filename: `logo.${extension}`,
};
} catch {
return null;
}
}
/**
* Teste la configuration SMTP d'un magasin sans envoyer de message.
*/
export async function verifySmtpConfig(group: StoreSmtpConfig): Promise<void> {
const transporter = createTransporter(group);
try {
await transporter.verify();
} finally {
transporter.close();
}
}
export interface SendSupplierMailResult {
messageId: string;
accepted: string[];
rejected: string[];
}
/**
* Envoie au fournisseur la demande de facture PDF / BL Excel pour une livraison.
*/
export async function sendSupplierDocumentRequest(
group: StoreSmtpConfig,
delivery: SupplierMailDelivery,
supplierEmail: string
): Promise<SendSupplierMailResult> {
const transporter = createTransporter(group);
try {
const store = {
name: group.name,
address: group.address,
phone: group.phone,
email: group.smtpSenderEmail,
};
const logo = parseLogoDataUri(group.logo);
const info = await transporter.sendMail({
from: buildFromAddress(group),
to: supplierEmail.trim(),
replyTo: group.smtpSenderEmail!.trim(),
subject: buildSupplierMailSubject(delivery),
text: buildSupplierMailText(delivery, store),
html: buildSupplierMailHtml(delivery, store, { hasLogo: Boolean(logo) }),
attachments: logo
? [{ ...logo, cid: 'logo', contentDisposition: 'inline' as const }]
: [],
});
return {
messageId: info.messageId,
accepted: (info.accepted || []).map(String),
rejected: (info.rejected || []).map(String),
};
} finally {
transporter.close();
}
}
+12 -5
View File
@@ -74,14 +74,21 @@ async function registerProductionRoutes(app: Express): Promise<void> {
});
});
// Emergency admin reset endpoint (production only)
// Emergency admin reset endpoint (production only)
// SÉCURITÉ : n'existe que si EMERGENCY_SECRET est défini dans l'environnement.
// L'ancien secret par défaut était codé en dur dans ce fichier, donc lisible
// par quiconque accède au dépôt — n'importe qui pouvait réinitialiser le
// compte admin en production.
app.post('/api/emergency-admin-reset', async (req: Request, res: Response) => {
try {
const emergencySecret = process.env.EMERGENCY_SECRET;
if (!emergencySecret) {
return res.status(404).json({ error: 'Not found' });
}
const { secret } = req.body;
// Require emergency secret
const emergencySecret = process.env.EMERGENCY_SECRET || 'logiflow-admin-reset-2025';
if (secret !== emergencySecret) {
if (!secret || secret !== emergencySecret) {
console.warn('🚨 Tentative de reset admin avec un secret invalide');
return res.status(403).json({ error: 'Invalid emergency secret' });
}
+2
View File
@@ -252,6 +252,7 @@ export class InvoiceVerificationService {
errorMessage?: string;
invoiceReference?: string;
invoiceAmount?: number;
invoiceAmountTTC?: number;
supplierName?: string;
dueDate?: string | null;
fromCache?: boolean;
@@ -614,6 +615,7 @@ export class InvoiceVerificationService {
errorMessage?: string;
invoiceReference?: string;
invoiceAmount?: number;
invoiceAmountTTC?: number;
supplierName?: string;
dueDate?: string | null;
fromCache?: boolean;
+75 -1
View File
@@ -1,4 +1,5 @@
import { Client } from 'pg';
import { encryptSecret, isEncryptedSecret } from './crypto.js';
export async function runProductionMigrations() {
const databaseUrl = process.env.DATABASE_URL;
@@ -58,7 +59,80 @@ export async function runProductionMigrations() {
} else {
console.log('✅ MIGRATION: Priority column already exists, skipping migration');
}
// Coordonnées magasin + configuration SMTP par magasin (mails fournisseurs)
console.log('🔄 MIGRATION: Ensuring store contact and SMTP columns on groups...');
await client.query(`
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
`);
console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups');
// Historique des mails de relance fournisseurs
console.log('🔄 MIGRATION: Ensuring supplier_mail_logs table...');
await client.query(`
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
id SERIAL PRIMARY KEY,
delivery_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
supplier_id INTEGER,
supplier_name VARCHAR(255),
sent_to VARCHAR(255) NOT NULL,
subject TEXT,
status VARCHAR(20) NOT NULL,
error_message TEXT,
message_id VARCHAR(255),
sent_by VARCHAR NOT NULL,
sent_by_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
`);
console.log('✅ MIGRATION: supplier_mail_logs table is present');
// Chiffrement au repos des secrets encore stockés en clair
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
// les valeurs déjà au format enc:v1: sont ignorées.
console.log('🔄 MIGRATION: Encrypting plaintext secrets at rest...');
let encryptedCount = 0;
const plainSmtp = await client.query(`
SELECT id, smtp_password FROM groups
WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%'
`);
for (const row of plainSmtp.rows) {
await client.query('UPDATE groups SET smtp_password = $1 WHERE id = $2', [
encryptSecret(row.smtp_password),
row.id,
]);
encryptedCount++;
}
const plainTokens = await client.query(`
SELECT id, api_token FROM nocodb_config
WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%'
`);
for (const row of plainTokens.rows) {
if (isEncryptedSecret(row.api_token)) continue;
await client.query('UPDATE nocodb_config SET api_token = $1 WHERE id = $2', [
encryptSecret(row.api_token),
row.id,
]);
encryptedCount++;
}
console.log(`✅ MIGRATION: Secrets encryption done (${encryptedCount} value(s) encrypted this run)`);
} catch (error) {
console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error);
console.error('❌ MIGRATION ERROR: Error details:', {
+41 -1
View File
@@ -60,12 +60,52 @@ export async function runMigrations() {
);
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
SELECT
SELECT
'Configuration BAP',
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
true
WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
},
{
filename: '20260814000000_add_store_contact_and_smtp_to_groups.sql',
content: `
-- Coordonnées du magasin (signature des mails fournisseurs)
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
-- Configuration SMTP propre à chaque magasin
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
`
},
{
filename: '20260814000001_create_supplier_mail_logs.sql',
content: `
CREATE TABLE IF NOT EXISTS supplier_mail_logs (
id SERIAL PRIMARY KEY,
delivery_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
supplier_id INTEGER,
supplier_name VARCHAR(255),
sent_to VARCHAR(255) NOT NULL,
subject TEXT,
status VARCHAR(20) NOT NULL,
error_message TEXT,
message_id VARCHAR(255),
sent_by VARCHAR NOT NULL,
sent_by_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
`
}
];
+229 -14
View File
@@ -3,6 +3,23 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import { setupLocalAuth, requireAuth } from "./localAuth";
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
import { stripSmtpPassword } from "./sanitize";
// Corps de requête sans les champs secrets : pour les logs uniquement
function redactBody(body: any): any {
if (!body || typeof body !== 'object') return body;
const redacted = { ...body };
for (const key of ['smtpPassword', 'apiToken', 'password']) {
if (key in redacted && redacted[key]) redacted[key] = '[REDACTED]';
}
return redacted;
}
import {
sendSupplierDocumentRequest,
verifySmtpConfig,
getMissingSmtpFields,
} from "./emailService";
import { buildSupplierMailSubject } from "@shared/supplierMail";
import { db, pool } from "./db";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
@@ -11,6 +28,7 @@ const require = createRequire(import.meta.url);
console.log('🔍 Using development storage and authentication');
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
function normalizeDateString(dateString: string | null | undefined): string | null {
if (!dateString || typeof dateString !== 'string') return null;
@@ -139,6 +157,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
const environment = process.env.NODE_ENV || 'development';
console.log('🌍 Environment detected:', environment);
// Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
// Les objets "group" sont joints à de nombreuses réponses (livraisons,
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
// nettoie une seule fois à la sortie. Le client reçoit à la place un booléen
// smtpPasswordSet lui indiquant si un mot de passe est enregistré.
app.use('/api', (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => originalJson(stripSmtpPassword(body));
next();
});
// Health check endpoint for Docker
app.get('/api/health', (req, res) => {
res.status(200).json({
@@ -967,7 +998,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
'user-agent': req.headers['user-agent']?.substring(0, 50) + '...'
});
console.log('📋 POST /api/groups - Request body:', JSON.stringify(req.body, null, 2));
console.log('📋 POST /api/groups - Request body:', JSON.stringify(redactBody(req.body), null, 2));
// Déterminer l'ID utilisateur selon l'environnement
let userId;
@@ -1016,7 +1047,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
console.error('❌ Failed to create group:', {
error: error?.message || 'Unknown error',
stack: error?.stack,
body: req.body,
body: redactBody(req.body),
userId: req.user?.id || req.user?.claims?.sub || 'unknown'
});
@@ -1042,6 +1073,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
const id = parseInt(req.params.id);
const data = insertGroupSchema.partial().parse(req.body);
// Le mot de passe SMTP n'est jamais renvoyé au client : un champ vide
// signifie "inchangé", pas "effacer". On ne l'écrase que s'il est fourni.
if (!data.smtpPassword) {
delete (data as any).smtpPassword;
}
const group = await storage.updateGroup(id, data);
res.json(group);
} catch (error: any) {
@@ -1050,6 +1088,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Test de la configuration SMTP d'un magasin (aucun message envoyé)
app.post('/api/groups/:id/test-smtp', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
if (!user || (user.role !== 'admin' && user.role !== 'manager')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
const id = parseInt(req.params.id);
const group = await storage.getGroup(id);
if (!group) {
return res.status(404).json({ message: "Magasin introuvable" });
}
const missing = getMissingSmtpFields(group as any);
if (missing.length > 0) {
return res.status(400).json({
success: false,
message: `Configuration incomplète : ${missing.join(', ')}`
});
}
await verifySmtpConfig(group as any);
res.json({ success: true, message: "Connexion au serveur SMTP réussie" });
} catch (error: any) {
console.error("Erreur test SMTP:", error);
res.status(400).json({
success: false,
message: error?.message || "Impossible de joindre le serveur SMTP"
});
}
});
app.delete('/api/groups/:id', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
@@ -1099,7 +1170,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
'content-length': req.headers['content-length']
});
console.log('📋 POST /api/suppliers - Request body:', JSON.stringify(req.body, null, 2));
console.log('📋 POST /api/suppliers - Request body:', JSON.stringify(redactBody(req.body), null, 2));
// Déterminer l'ID utilisateur selon l'environnement
let userId;
@@ -1148,7 +1219,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
console.error('❌ Failed to create supplier:', {
error: (error as Error).message,
stack: error.stack,
body: req.body,
body: redactBody(req.body),
userId: req.user?.id || req.user?.claims?.sub || 'unknown'
});
@@ -1482,7 +1553,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
console.log('📦 Order creation started:', {
userId: req.user?.id || req.user?.claims?.sub,
body: req.body,
body: redactBody(req.body),
environment: process.env.NODE_ENV
});
@@ -1529,7 +1600,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
console.error("❌ Error creating order:", {
error: (error as Error).message,
stack: error.stack,
body: req.body,
body: redactBody(req.body),
userId: req.user?.id || req.user?.claims?.sub || 'unknown'
});
@@ -2254,6 +2325,144 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
// Route de vérification de facture NocoDB
// Envoi au fournisseur de la demande de facture (PDF) ou de BL (Excel)
// via le serveur SMTP configuré sur la fiche du magasin de la livraison
app.post('/api/deliveries/:id/send-supplier-mail', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
const deliveryId = parseInt(req.params.id);
const delivery = await storage.getDelivery(deliveryId);
if (!delivery) {
return res.status(404).json({ message: "Livraison introuvable" });
}
if (!hasPermission(user.role, 'deliveries', 'view')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
// Hors admin, l'utilisateur doit appartenir au magasin de la livraison
if (user.role !== 'admin') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (!userGroupIds.includes(delivery.groupId)) {
return res.status(403).json({ message: "Access denied to this group" });
}
}
// Adresse du fournisseur : celle enregistrée sur sa fiche
const supplierEmail = delivery.supplier?.email?.trim();
if (!supplierEmail) {
return res.status(400).json({
message: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
});
}
// Configuration SMTP du magasin (mot de passe inclus : usage serveur uniquement)
const group = await storage.getGroup(delivery.groupId);
if (!group) {
return res.status(404).json({ message: "Magasin de la livraison introuvable" });
}
if (!(group as any).smtpEnabled) {
return res.status(400).json({
message: `L'envoi de mails n'est pas activé pour le magasin ${group.name}. Renseignez la configuration SMTP sur sa fiche.`
});
}
const missing = getMissingSmtpFields(group as any);
if (missing.length > 0) {
return res.status(400).json({
message: `Configuration SMTP incomplète pour ${group.name} : ${missing.join(', ')}`
});
}
// Historisation de la tentative, succès comme échec — un échec de
// journalisation ne doit jamais faire échouer (ni annuler) l'envoi
const senderName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|| user.username || user.id;
const logAttempt = async (status: 'sent' | 'failed', extra: { messageId?: string; errorMessage?: string }) => {
try {
await storage.createSupplierMailLog({
deliveryId,
groupId: delivery.groupId,
supplierId: delivery.supplierId ?? null,
supplierName: delivery.supplier?.name || null,
sentTo: supplierEmail,
subject: buildSupplierMailSubject(delivery as any),
status,
errorMessage: extra.errorMessage || null,
messageId: extra.messageId || null,
sentBy: user.id,
sentByName: senderName,
});
} catch (logError) {
console.error('⚠️ Historisation du mail fournisseur impossible:', logError);
}
};
try {
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
await logAttempt('sent', { messageId: result.messageId });
console.log('📧 Mail fournisseur envoyé:', {
deliveryId,
supplier: delivery.supplier?.name,
to: supplierEmail,
store: group.name,
messageId: result.messageId
});
res.json({
success: true,
sentTo: supplierEmail,
supplierName: delivery.supplier?.name || null,
messageId: result.messageId
});
} catch (sendError: any) {
await logAttempt('failed', { errorMessage: sendError?.message || 'Erreur inconnue' });
throw sendError;
}
} catch (error: any) {
console.error("Erreur envoi mail fournisseur:", error);
res.status(500).json({
message: error?.message || "Impossible d'envoyer le mail au fournisseur"
});
}
});
// Historique des relances fournisseurs, restreint aux magasins de l'utilisateur
app.get('/api/supplier-mail-logs', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
const deliveryId = req.query.deliveryId ? parseInt(req.query.deliveryId as string) : undefined;
let groupIds: number[] | undefined;
if (user.role !== 'admin') {
groupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (groupIds.length === 0) {
return res.json([]);
}
} else if (req.query.storeId) {
groupIds = [parseInt(req.query.storeId as string)];
}
const logs = await storage.getSupplierMailLogs(groupIds, deliveryId);
res.json(logs);
} catch (error) {
console.error("Erreur lecture historique mails fournisseurs:", error);
res.status(500).json({ message: "Failed to fetch supplier mail logs" });
}
});
app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
@@ -2479,7 +2688,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
// Vérifier que la livraison existe
const delivery = await storage.getDeliveryById(id);
const delivery = await storage.getDelivery(id);
if (!delivery) {
return res.status(404).json({ message: "Delivery not found" });
}
@@ -4520,7 +4729,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
res.json({ message: "Publicity deleted successfully" });
} catch (error) {
console.error(`❌ [API] Error deleting publicity ${publicityId}:`, error);
res.status(500).json({ message: "Failed to delete publicity", error: error.message });
res.status(500).json({ message: "Failed to delete publicity", error: (error as Error).message });
}
});
@@ -4559,7 +4768,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
res.json({ message: "Publicity deleted successfully" });
} catch (error) {
console.error(`❌ [API-POST] Error deleting publicity ${publicityId} via POST:`, error);
res.status(500).json({ message: "Failed to delete publicity", error: error.message });
res.status(500).json({ message: "Failed to delete publicity", error: (error as Error).message });
}
});
@@ -5349,8 +5558,14 @@ RÉSUMÉ DU SCAN
});
// Emergency migration route for SAV priority column
app.post('/api/admin/emergency-migration', async (req, res) => {
// SÉCURITÉ : réservée aux administrateurs authentifiés (était accessible sans login)
app.post('/api/admin/emergency-migration', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
if (!user || user.role !== 'admin') {
return res.status(403).json({ message: "Insufficient permissions" });
}
console.log('🚨 EMERGENCY: Forcing SAV migration execution...');
// Import migration function
@@ -6010,7 +6225,7 @@ RÉSUMÉ DU SCAN
if (user.role !== 'admin' && user.role !== 'directeur') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
filters.groupIds = filters.groupIds
? filters.groupIds.filter(id => userGroupIds.includes(id))
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
: userGroupIds;
}
@@ -6041,7 +6256,7 @@ RÉSUMÉ DU SCAN
if (user.role !== 'admin' && user.role !== 'directeur') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
filters.groupIds = filters.groupIds
? filters.groupIds.filter(id => userGroupIds.includes(id))
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
: userGroupIds;
}
@@ -6070,7 +6285,7 @@ RÉSUMÉ DU SCAN
if (user.role !== 'admin' && user.role !== 'directeur') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
filters.groupIds = filters.groupIds
? filters.groupIds.filter(id => userGroupIds.includes(id))
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
: userGroupIds;
}
@@ -6123,7 +6338,7 @@ RÉSUMÉ DU SCAN
if (user.role !== 'admin' && user.role !== 'directeur') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
filters.groupIds = filters.groupIds
? filters.groupIds.filter(id => userGroupIds.includes(id))
? filters.groupIds.filter((id: number) => userGroupIds.includes(id))
: userGroupIds;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Nettoyage des données sensibles avant envoi au client.
*/
/**
* Retire récursivement le mot de passe SMTP des réponses API et le remplace par
* un indicateur de présence (smtpPasswordSet). Traverse les objets et tableaux
* imbriqués car les magasins apparaissent sous plusieurs formes : liste de
* groupes, champ "group" d'une livraison ou d'une commande, relations
* utilisateur... Filtrer chaque requête serait fragile, on nettoie donc une
* seule fois à la sortie.
*/
export function stripSmtpPassword(value: any, depth = 0, seen = new WeakSet()): any {
if (depth > 8 || value === null || typeof value !== 'object') {
return value;
}
// Les structures cycliques sont renvoyées telles quelles plutôt que de
// faire boucler la récursion
if (seen.has(value)) {
return value;
}
seen.add(value);
if (Array.isArray(value)) {
return value.map(item => stripSmtpPassword(item, depth + 1, seen));
}
// Ne pas dénaturer les types non sérialisables en objets simples
if (value instanceof Date || Buffer.isBuffer(value)) {
return value;
}
const result: Record<string, any> = {};
for (const [key, entry] of Object.entries(value)) {
if (key === 'smtpPassword' || key === 'smtp_password') {
result.smtpPasswordSet = Boolean(entry);
continue;
}
result[key] = stripSmtpPassword(entry, depth + 1, seen);
}
return result;
}
File diff suppressed because it is too large. Load diff
+140 -35
View File
@@ -13,6 +13,7 @@ import {
dashboardMessages,
announcements,
nocodbConfig,
supplierMailLogs,
invoiceVerificationCache,
reconciliationComments,
savTickets,
@@ -56,12 +57,16 @@ import {
type ReconciliationComment,
type InsertReconciliationComment,
type ReconciliationCommentWithRelations,
type DashboardMessage,
type NocodbConfig,
type InsertNocodbConfig,
type SupplierMailLog,
type InsertSupplierMailLog,
type InvoiceVerificationCache,
type InsertInvoiceVerificationCache,
type SavTicket,
type InsertSavTicket,
type SavTicketHistory,
type InsertSavTicketHistory,
type SavTicketWithRelations,
type SavTicketHistoryWithCreator,
@@ -83,6 +88,7 @@ import {
type Contact,
type InsertContact,
} from "@shared/schema";
import { encryptSecret, decryptSecret } from "./crypto";
import { db } from "./db";
import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne } from "drizzle-orm";
import { getAnnouncementStorage } from "./announcementStorage";
@@ -198,7 +204,7 @@ export interface IStorage {
// Client call tracking
getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]>;
markClientCalled(customerOrderId: number, calledBy: string): Promise<CustomerOrder>;
markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise<CustomerOrder>;
// DLC Product operations
getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise<DlcProductWithRelations[]>;
@@ -224,8 +230,8 @@ export interface IStorage {
// Announcement operations
createAnnouncement(announcement: InsertAnnouncement): Promise<Announcement>;
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined>;
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations>;
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined>;
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage>;
deleteAnnouncement(id: number): Promise<boolean>;
// Avoir operations
@@ -270,6 +276,10 @@ export interface IStorage {
deleteOldWeatherData(daysToKeep: number): Promise<void>;
clearWeatherCache(): Promise<void>;
// Historique des mails fournisseurs (rapprochement)
createSupplierMailLog(log: InsertSupplierMailLog): Promise<SupplierMailLog>;
getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]>;
// Webhook BAP Configuration
getWebhookBapConfig(): Promise<WebhookBapConfig | undefined>;
createWebhookBapConfig(config: InsertWebhookBapConfig): Promise<WebhookBapConfig>;
@@ -428,14 +438,21 @@ export class DatabaseStorage implements IStorage {
}
async createGroup(groupData: InsertGroup): Promise<Group> {
const [group] = await db.insert(groups).values(groupData).returning();
// Le mot de passe SMTP est chiffré au repos ; il n'est déchiffré qu'au
// moment de l'envoi d'un mail (emailService)
const values = { ...groupData, smtpPassword: encryptSecret(groupData.smtpPassword) };
const [group] = await db.insert(groups).values(values).returning();
return group;
}
async updateGroup(id: number, groupData: Partial<InsertGroup>): Promise<Group> {
const values = { ...groupData };
if (values.smtpPassword !== undefined) {
values.smtpPassword = encryptSecret(values.smtpPassword);
}
const [group] = await db
.update(groups)
.set({ ...groupData, updatedAt: new Date() })
.set({ ...values, updatedAt: new Date() })
.where(eq(groups.id, id))
.returning();
return group;
@@ -1506,7 +1523,7 @@ export class DatabaseStorage implements IStorage {
// LOG: Debug des publicités récupérées
console.log(`📋 PUBLICITES FETCHED: ${results.length} résultats pour année ${year || 'toutes'}`);
if (results.length > 0) {
console.log('🔍 PREMIERS RESULTATS:', results.slice(0, 3).map((p, i) => `${i + 1}. N°${p.pubNumber} - ${p.designation}`));
console.log('🔍 PREMIERS RESULTATS:', results.slice(0, 3).map((p: any, i: any) => `${i + 1}. N°${p.pubNumber} - ${p.designation}`));
}
const publicityIds = results.map((p: any) => p.id);
@@ -1523,7 +1540,7 @@ export class DatabaseStorage implements IStorage {
: [];
// Sort by pubNumber as integer on the server side for consistency
const sortedResults = results.sort((a, b) => {
const sortedResults = results.sort((a: any, b: any) => {
const numA = parseInt(a.pubNumber) || 0;
const numB = parseInt(b.pubNumber) || 0;
return numA - numB;
@@ -1622,13 +1639,26 @@ export class DatabaseStorage implements IStorage {
}
// NocoDB Configuration operations
// Le jeton API est chiffré en base ; les lecteurs (vérification de facture,
// page d'admin) reçoivent la valeur en clair comme avant le chiffrement
private decryptNocodbConfig<T extends NocodbConfig | undefined>(config: T): T {
if (!config) return config;
try {
return { ...config, apiToken: decryptSecret(config.apiToken) } as T;
} catch (error) {
console.error(`❌ Jeton NocoDB illisible (config ${config.id}):`, error);
return { ...config, apiToken: '' } as T;
}
}
async getNocodbConfigs(): Promise<NocodbConfig[]> {
return await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c));
}
async getNocodbConfig(id: number): Promise<NocodbConfig | undefined> {
const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id));
return config;
return this.decryptNocodbConfig(config);
}
async getActiveNocodbConfig(): Promise<NocodbConfig | undefined> {
@@ -1639,8 +1669,9 @@ export class DatabaseStorage implements IStorage {
.where(eq(nocodbConfig.isActive, true))
.limit(1);
console.log('🔧 Configuration NocoDB active récupérée:', config);
return config;
// Ne pas tracer le jeton en clair dans les logs
console.log('🔧 Configuration NocoDB active récupérée:', config?.id, config?.name);
return this.decryptNocodbConfig(config);
} catch (error) {
console.error('❌ Erreur récupération config NocoDB:', error);
return undefined;
@@ -1648,17 +1679,22 @@ export class DatabaseStorage implements IStorage {
}
async createNocodbConfig(configData: InsertNocodbConfig): Promise<NocodbConfig> {
const [config] = await db.insert(nocodbConfig).values(configData).returning();
return config;
const values = { ...configData, apiToken: encryptSecret(configData.apiToken) || '' };
const [config] = await db.insert(nocodbConfig).values(values).returning();
return this.decryptNocodbConfig(config);
}
async updateNocodbConfig(id: number, configData: Partial<InsertNocodbConfig>): Promise<NocodbConfig> {
const values = { ...configData };
if (values.apiToken !== undefined) {
values.apiToken = encryptSecret(values.apiToken) || '';
}
const [config] = await db
.update(nocodbConfig)
.set({ ...configData, updatedAt: new Date() })
.set({ ...values, updatedAt: new Date() })
.where(eq(nocodbConfig.id, id))
.returning();
return config;
return this.decryptNocodbConfig(config);
}
async deleteNocodbConfig(id: number): Promise<void> {
@@ -1913,7 +1949,7 @@ export class DatabaseStorage implements IStorage {
.where(and(...conditions))
.orderBy(desc(customerOrders.createdAt));
return results.map(result => ({
return results.map((result: any) => ({
...result.customerOrder,
supplier: result.supplier!,
group: result.group!,
@@ -1967,8 +2003,8 @@ export class DatabaseStorage implements IStorage {
in15Days.setHours(23, 59, 59, 999); // End of day
conditions.push(
and(
gt(dlcProducts.expiryDate, today),
lte(dlcProducts.expiryDate, in15Days),
gt(dlcProducts.expiryDate, today as any),
lte(dlcProducts.expiryDate, in15Days as any),
ne(dlcProducts.status, 'valides'),
or(
isNull(dlcProducts.processedUntilExpiry),
@@ -1981,7 +2017,7 @@ export class DatabaseStorage implements IStorage {
// Ne pas afficher les produits déjà traités (processedUntilExpiry = true)
conditions.push(
and(
lte(dlcProducts.expiryDate, today),
lte(dlcProducts.expiryDate, today as any),
ne(dlcProducts.status, 'valides'),
or(
isNull(dlcProducts.processedUntilExpiry),
@@ -1996,7 +2032,7 @@ export class DatabaseStorage implements IStorage {
in15Days.setHours(23, 59, 59, 999);
conditions.push(
and(
gt(dlcProducts.expiryDate, in15Days),
gt(dlcProducts.expiryDate, in15Days as any),
ne(dlcProducts.status, 'valides')
)
);
@@ -2266,7 +2302,7 @@ export class DatabaseStorage implements IStorage {
console.log('📋 DatabaseStorage.getTasks - Raw results:', {
resultCount: results.length,
userRole,
sampleTasks: results.slice(0, 2).map(r => ({
sampleTasks: results.slice(0, 2).map((r: any) => ({
taskExists: !!r.task,
taskId: r.task?.id,
title: r.task?.title,
@@ -2377,7 +2413,7 @@ export class DatabaseStorage implements IStorage {
// Gestion spéciale des dates selon les types PostgreSQL
if (taskData.startDate !== undefined) {
if (taskData.startDate === '' || taskData.startDate === null) {
if ((taskData.startDate as any) === '' || taskData.startDate === null) {
cleanData.startDate = null;
} else {
// start_date est un timestamp en PostgreSQL - on peut passer une date
@@ -2386,7 +2422,7 @@ export class DatabaseStorage implements IStorage {
}
if (taskData.dueDate !== undefined) {
if (taskData.dueDate === '' || taskData.dueDate === null) {
if ((taskData.dueDate as any) === '' || taskData.dueDate === null) {
cleanData.dueDate = null;
} else {
// due_date est un timestamp en PostgreSQL - convertir en Date object
@@ -2463,7 +2499,7 @@ export class DatabaseStorage implements IStorage {
return await announcementStorage.getAnnouncements(groupIds);
}
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined> {
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
const announcementStorage = getAnnouncementStorage(
() => this.getUsers(),
() => this.getGroups()
@@ -2471,7 +2507,7 @@ export class DatabaseStorage implements IStorage {
return await announcementStorage.getAnnouncement(id);
}
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations> {
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<DashboardMessage> {
const announcementStorage = getAnnouncementStorage(
() => this.getUsers(),
() => this.getGroups()
@@ -2665,7 +2701,7 @@ export class DatabaseStorage implements IStorage {
// Get history for each ticket
const ticketsWithHistory = await Promise.all(
results.map(async (result) => {
results.map(async (result: any) => {
const history = await this.getSavTicketHistory(result.ticket.id);
return {
...result.ticket,
@@ -2826,7 +2862,7 @@ export class DatabaseStorage implements IStorage {
};
// Process status results
statusResults.forEach(result => {
statusResults.forEach((result: any) => {
const count = Number(result.count || 0);
if (result.status === 'nouveau') {
@@ -2839,7 +2875,7 @@ export class DatabaseStorage implements IStorage {
});
// Process priority results for critical tickets
priorityResults.forEach(result => {
priorityResults.forEach((result: any) => {
const count = Number(result.count || 0);
if (result.priority === 'critique') {
@@ -2912,6 +2948,29 @@ export class DatabaseStorage implements IStorage {
console.log('🧹 Weather cache cleared due to location change');
}
// Historique des mails fournisseurs (rapprochement)
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
const [log] = await db.insert(supplierMailLogs).values(logData).returning();
return log;
}
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
const conditions = [];
if (groupIds && groupIds.length > 0) {
conditions.push(inArray(supplierMailLogs.groupId, groupIds));
}
if (deliveryId !== undefined) {
conditions.push(eq(supplierMailLogs.deliveryId, deliveryId));
}
let query = db.select().from(supplierMailLogs).$dynamic();
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
// Borné : la page de rapprochement n'a besoin que de l'historique récent
return await query.orderBy(desc(supplierMailLogs.createdAt)).limit(500);
}
// Webhook BAP Configuration
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
const [config] = await db.select().from(webhookBapConfig).limit(1);
@@ -2998,7 +3057,7 @@ export class DatabaseStorage implements IStorage {
.where(eq(reconciliationComments.deliveryId, deliveryId))
.orderBy(desc(reconciliationComments.createdAt));
return comments.map(comment => ({
return comments.map((comment: any) => ({
...comment,
delivery: {
...comment.delivery,
@@ -3187,13 +3246,13 @@ export class DatabaseStorage implements IStorage {
reconciliationRate: deliveryStats.count ? (Number(deliveryStats.reconciled) / Number(deliveryStats.count)) * 100 : 0,
totalAmount: Number(deliveryStats.totalAmount) || 0,
avgDeliveryDelay: Number(deliveryStats.avgDelay) || 0,
topSuppliers: topSuppliers.map(s => ({
topSuppliers: topSuppliers.map((s: any) => ({
id: s.id,
name: s.name,
count: Number(s.count),
amount: Number(s.amount)
})),
topStores: topStores.map(s => ({
topStores: topStores.map((s: any) => ({
id: s.id,
name: s.name,
orders: Number(s.orders),
@@ -3313,7 +3372,7 @@ export class DatabaseStorage implements IStorage {
.groupBy(suppliers.id, suppliers.name)
.orderBy(desc(sql<number>`COUNT(${deliveries.id})`));
return result.map(row => ({
return result.map((row: any) => ({
supplierId: row.supplierId,
supplierName: row.supplierName,
deliveries: Number(row.deliveries),
@@ -4276,7 +4335,7 @@ export class MemStorage implements IStorage {
}));
}
async markClientCalled(customerOrderId: number, calledBy: string): Promise<CustomerOrder> {
async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise<CustomerOrder> {
const order = this.customerOrders.get(customerOrderId);
if (!order) {
throw new Error(`CustomerOrder with id ${customerOrderId} not found`);
@@ -5209,6 +5268,52 @@ export class MemStorage implements IStorage {
console.log('🧹 DEV: Weather cache cleared due to location change');
}
async markDeliveryControlValidated(id: number, userId: string): Promise<void> {
const delivery = this.deliveries.get(id);
if (delivery) {
this.deliveries.set(id, {
...delivery,
controlValidated: true,
controlValidatedBy: userId,
controlValidatedAt: new Date(),
updatedAt: new Date(),
});
}
}
// Historique des mails fournisseurs (en mémoire pour le développement)
private supplierMailLogsStore: SupplierMailLog[] = [];
private supplierMailLogIdCounter = 1;
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
const log: SupplierMailLog = {
id: this.supplierMailLogIdCounter++,
deliveryId: logData.deliveryId,
groupId: logData.groupId,
supplierId: logData.supplierId ?? null,
supplierName: logData.supplierName ?? null,
sentTo: logData.sentTo,
subject: logData.subject ?? null,
status: logData.status,
errorMessage: logData.errorMessage ?? null,
messageId: logData.messageId ?? null,
sentBy: logData.sentBy,
sentByName: logData.sentByName ?? null,
createdAt: new Date(),
};
this.supplierMailLogsStore.unshift(log);
return log;
}
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
return this.supplierMailLogsStore
.filter(log =>
(!groupIds || groupIds.length === 0 || groupIds.includes(log.groupId)) &&
(deliveryId === undefined || log.deliveryId === deliveryId)
)
.slice(0, 500);
}
// Webhook BAP Configuration
async getWebhookBapConfig(): Promise<WebhookBapConfig | undefined> {
// En développement, retourner une config par défaut
@@ -5284,7 +5389,7 @@ export class MemStorage implements IStorage {
return await announcementStorage.getAnnouncements(groupIds);
}
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined> {
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
const announcementStorage = getAnnouncementStorage(
async () => Array.from(this.users.values()),
async () => Array.from(this.groups.values())
@@ -5292,7 +5397,7 @@ export class MemStorage implements IStorage {
return await announcementStorage.getAnnouncement(id);
}
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations> {
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<DashboardMessage> {
const announcementStorage = getAnnouncementStorage(
async () => Array.from(this.users.values()),
async () => Array.from(this.groups.values())
+49
View File
@@ -61,6 +61,19 @@ export const groups = pgTable("groups", {
nocodbSupplierColumnName: varchar("nocodb_supplier_column_name"), // Nom de la colonne fournisseur dans leur table
nocodbDueDateColumnName: varchar("nocodb_due_date_column_name"), // Nom de la colonne date d'échéance dans leur table
webhookUrl: varchar("webhook_url", { length: 500 }), // URL de webhook pour notifications par magasin
// Coordonnées du magasin, reprises dans la signature des mails fournisseurs
address: text("address"), // Adresse postale
phone: varchar("phone", { length: 50 }), // Téléphone
logo: text("logo"), // Logo en data URI (data:image/png;base64,...), intégré aux mails
// Configuration SMTP propre au magasin (envoi des mails fournisseurs)
smtpEnabled: boolean("smtp_enabled").default(false),
smtpHost: varchar("smtp_host", { length: 255 }),
smtpPort: integer("smtp_port"),
smtpSecure: boolean("smtp_secure").default(false), // true = SSL/TLS direct (465), false = STARTTLS
smtpUser: varchar("smtp_user", { length: 255 }),
smtpPassword: varchar("smtp_password", { length: 255 }), // jamais renvoyé au client
smtpSenderEmail: varchar("smtp_sender_email", { length: 255 }), // adresse expéditeur "De :"
smtpSenderName: varchar("smtp_sender_name", { length: 255 }), // nom affiché, par défaut le nom du magasin
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
@@ -554,6 +567,17 @@ export const insertGroupSchema = createInsertSchema(groups).omit({
id: true,
createdAt: true,
updatedAt: true,
}).extend({
// Champs optionnels : le formulaire envoie "" quand ils ne sont pas renseignés
address: z.string().optional().nullable(),
phone: z.string().optional().nullable(),
logo: z.string().optional().nullable(),
smtpHost: z.string().optional().nullable(),
smtpPort: z.coerce.number().int().min(1).max(65535).optional().nullable(),
smtpUser: z.string().optional().nullable(),
smtpPassword: z.string().optional().nullable(),
smtpSenderEmail: z.union([z.string().email(), z.literal("")]).optional().nullable(),
smtpSenderName: z.string().optional().nullable(),
});
export const insertSupplierSchema = createInsertSchema(suppliers).omit({
@@ -961,6 +985,23 @@ export type WeatherSettings = typeof weatherSettings.$inferSelect;
export type InsertWeatherSettings = z.infer<typeof insertWeatherSettingsSchema>;
// Configuration Webhook BAP
// Historique des mails de relance envoyés aux fournisseurs (rapprochement)
export const supplierMailLogs = pgTable("supplier_mail_logs", {
id: serial("id").primaryKey(),
deliveryId: integer("delivery_id").notNull(),
groupId: integer("group_id").notNull(),
supplierId: integer("supplier_id"),
supplierName: varchar("supplier_name", { length: 255 }), // figé au moment de l'envoi
sentTo: varchar("sent_to", { length: 255 }).notNull(), // adresse destinataire
subject: text("subject"),
status: varchar("status", { length: 20 }).notNull(), // 'sent' | 'failed'
errorMessage: text("error_message"),
messageId: varchar("message_id", { length: 255 }),
sentBy: varchar("sent_by").notNull(), // id utilisateur
sentByName: varchar("sent_by_name", { length: 255 }), // nom lisible, figé
createdAt: timestamp("created_at").defaultNow(),
});
export const webhookBapConfig = pgTable("webhook_bap_config", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull().default("Configuration BAP"),
@@ -981,3 +1022,11 @@ export const insertWebhookBapConfigSchema = createInsertSchema(webhookBapConfig)
// Webhook BAP Types
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
export type InsertWebhookBapConfig = z.infer<typeof insertWebhookBapConfigSchema>;
// Supplier mail logs
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
id: true,
createdAt: true,
});
export type SupplierMailLog = typeof supplierMailLogs.$inferSelect;
export type InsertSupplierMailLog = z.infer<typeof insertSupplierMailLogSchema>;
+180
View File
@@ -0,0 +1,180 @@
/**
* Contenu des mails de relance fournisseur (rapprochement BL/Factures).
* Partagé client/serveur : le serveur construit et envoie le mail via le SMTP
* du magasin, le client peut réutiliser l'objet pour l'affichage.
*/
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;
}
/** Coordonnées du magasin reprises dans la signature. */
export interface StoreSignature {
name?: string | null;
address?: string | null;
phone?: string | null;
email?: string | null;
}
/** Formatage jj/mm/aaaa, tolérant aux valeurs absentes ou invalides. */
function formatDate(date: string | Date | null | undefined): string {
if (!date) return "";
try {
const dateObj = typeof date === "string" ? new Date(date) : date;
if (isNaN(dateObj.getTime())) return "";
const day = String(dateObj.getDate()).padStart(2, "0");
const month = String(dateObj.getMonth() + 1).padStart(2, "0");
return `${day}/${month}/${dateObj.getFullYear()}`;
} catch {
return "";
}
}
/**
* Objet du mail : identifie la livraison concernée.
*/
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
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(" - ");
}
/**
* Récapitulatif de la livraison : seules les informations renseignées sont listées.
*/
function buildDeliveryDetails(delivery: SupplierMailDelivery): string[] {
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
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}`);
}
return details.length > 0 ? details : ["(informations de livraison à préciser)"];
}
/**
* Version texte du mail (repli pour les clients sans HTML).
*/
export function buildSupplierMailText(
delivery: SupplierMailDelivery,
store?: StoreSignature | null
): string {
const details = buildDeliveryDetails(delivery).map(line => `- ${line}`);
const signature = [store?.name, store?.address, store?.phone ? `Tél. ${store.phone}` : null, store?.email]
.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,
"",
"Vous en remerciant par avance.",
"",
"Cordialement,",
...(signature ? [signature] : []),
];
return lines.join("\n");
}
/** Échappement HTML des valeurs injectées dans le corps du mail. */
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Version HTML du mail, avec la signature du magasin et son logo.
* Le logo est référencé par `cid:logo` : il est joint au message par
* l'expéditeur, ce qui évite les images distantes bloquées par Outlook.
*/
export function buildSupplierMailHtml(
delivery: SupplierMailDelivery,
store?: StoreSignature | null,
options: { hasLogo?: boolean } = {}
): string {
const details = buildDeliveryDetails(delivery)
.map(line => `<li>${escapeHtml(line)}</li>`)
.join("");
const signatureLines = [
store?.name ? `<strong>${escapeHtml(store.name)}</strong>` : null,
store?.address ? escapeHtml(store.address).replace(/\n/g, "<br>") : null,
store?.phone ? `Tél. ${escapeHtml(store.phone)}` : null,
store?.email
? `<a href="mailto:${escapeHtml(store.email)}">${escapeHtml(store.email)}</a>`
: null,
].filter(Boolean);
const logoBlock = options.hasLogo
? '<p style="margin:0 0 8px 0;"><img src="cid:logo" alt="" style="max-width:200px;height:auto;border:0;"></p>'
: "";
const signatureBlock = signatureLines.length > 0 || logoBlock
? `${logoBlock}<p style="margin:0;color:#444;font-size:13px;line-height:1.5;">${signatureLines.join("<br>")}</p>`
: "";
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5;">
<p>Bonjour,</p>
<p>Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre&nbsp;:</p>
<ul>
<li>la facture au format PDF, ou</li>
<li>le bon de livraison au format Excel</li>
</ul>
<p style="margin-bottom:4px;">Livraison concernée&nbsp;:</p>
<ul>${details}</ul>
<p>Vous en remerciant par avance.</p>
<p style="margin-bottom:12px;">Cordialement,</p>
${signatureBlock}
</div>`;
}
-35
View File
@@ -1,35 +0,0 @@
#!/bin/bash
# Script de test d'accès à la base de données
echo "🔍 Test d'accès à la base de données..."
# Test de connexion
echo "1. Test de connexion à la base..."
docker exec logiflow-logiflow-1 psql "$DATABASE_URL" -c "SELECT version();" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✅ Connexion à la base réussie"
else
echo "❌ Erreur de connexion à la base"
exit 1
fi
# Liste des tables existantes
echo "2. Tables existantes dans la base:"
docker exec logiflow-logiflow-1 psql "$DATABASE_URL" -c "
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
"
# Vérification spécifique de la table announcements
echo "3. Vérification de la table announcements:"
docker exec logiflow-logiflow-1 psql "$DATABASE_URL" -c "
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'announcements'
) as announcements_exists;
"
echo "✅ Test terminé!"
-124
View File
@@ -1,124 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Task Update - Production Debug</title>
<style>
body { font-family: monospace; padding: 20px; }
.log { margin: 10px 0; padding: 10px; background: #f5f5f5; }
.error { background: #ffe6e6; }
.success { background: #e6ffe6; }
button { padding: 10px; margin: 5px; }
textarea, input { width: 100%; margin: 5px 0; }
</style>
</head>
<body>
<h1>Test Task Update - Production Debug</h1>
<div>
<h3>1. D'abord, connectez-vous manuellement sur LogiFlow</h3>
<p>Ouvrez <a href="https://logiflow.ffnancy.fr" target="_blank">LogiFlow</a> dans un autre onglet et connectez-vous avec vos vrais identifiants.</p>
</div>
<div>
<h3>2. Test de l'endpoint problématique</h3>
<label>Task ID à tester:</label>
<input type="number" id="taskId" value="99" />
<label>JSON des données à envoyer:</label>
<textarea id="taskData" rows="10">{
"title": "Test Debug",
"description": "Test de debug depuis le script",
"priority": "medium",
"status": "pending",
"assignedTo": "admin",
"startDate": "2025-08-20",
"dueDate": null
}</textarea>
<button onclick="testTaskUpdate()">Test Task Update (PUT)</button>
<button onclick="clearLogs()">Clear Logs</button>
</div>
<div id="logs"></div>
<script>
function log(message, type = 'info') {
const logs = document.getElementById('logs');
const div = document.createElement('div');
div.className = `log ${type}`;
div.innerHTML = `[${new Date().toLocaleTimeString()}] ${message}`;
logs.insertBefore(div, logs.firstChild);
}
function clearLogs() {
document.getElementById('logs').innerHTML = '';
}
async function testTaskUpdate() {
const taskId = document.getElementById('taskId').value;
const taskData = document.getElementById('taskData').value;
log(`Testing task update for ID: ${taskId}`);
try {
// Parse JSON data
const data = JSON.parse(taskData);
log(`Parsed data: ${JSON.stringify(data, null, 2)}`);
// Test the problematic endpoint
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin', // Important pour envoyer les cookies de session
body: taskData
});
log(`Response status: ${response.status} ${response.statusText}`);
if (response.ok) {
const result = await response.json();
log(`Success! Response: ${JSON.stringify(result, null, 2)}`, 'success');
} else {
const error = await response.text();
log(`Error response: ${error}`, 'error');
// Additional debug info
log(`Response headers: ${JSON.stringify([...response.headers.entries()])}`, 'error');
}
} catch (error) {
log(`JavaScript error: ${error.message}`, 'error');
log(`Stack trace: ${error.stack}`, 'error');
}
}
// Test connectivity on load
window.onload = function() {
log('Script loaded. Please login to LogiFlow first!');
// Test if we can reach the API
fetch('/api/user')
.then(response => {
if (response.status === 401) {
log('Not authenticated - please login first', 'error');
} else if (response.ok) {
log('Authenticated successfully', 'success');
return response.json();
} else {
log(`Unexpected status: ${response.status}`, 'error');
}
})
.then(user => {
if (user) {
log(`Logged in as: ${user.username} (${user.role})`, 'success');
}
})
.catch(error => {
log(`Connection test failed: ${error.message}`, 'error');
});
};
</script>
</body>
</html>
+1 -3
View File
@@ -34,9 +34,7 @@
"vite/client",
"express",
"react",
"react-dom",
"node-fetch",
"date-fns"
"react-dom"
],
"paths": {
"@/*": [
-73
View File
@@ -1,73 +0,0 @@
#!/bin/bash
# Script de correction urgente pour la production
# Applique la migration DLC immédiatement
set -e
echo "🚨 CORRECTION URGENTE - Migration DLC Production"
echo "================================================"
# Vérifier que Docker Compose est disponible
if ! command -v docker-compose &> /dev/null; then
echo "❌ docker-compose non trouvé"
exit 1
fi
# Vérifier que le conteneur PostgreSQL est en cours d'exécution
if ! docker-compose ps | grep -q logiflow-db; then
echo "❌ Conteneur logiflow-db non trouvé"
echo "Containers disponibles:"
docker-compose ps
exit 1
fi
echo "✅ Conteneur PostgreSQL trouvé"
# Appliquer la migration DLC
echo "🔄 Application de la migration DLC..."
docker-compose exec -T logiflow-db psql -U logiflow_admin -d logiflow_db << 'EOF'
-- Migration DLC Stock Épuisé
ALTER TABLE dlc_products
ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp;
-- Index pour performance
CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);
-- Vérification
SELECT 'Migration DLC appliquée avec succès' as status;
-- Afficher les colonnes ajoutées
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'dlc_products'
AND column_name LIKE '%stock_epuise%'
ORDER BY column_name;
EOF
if [ $? -eq 0 ]; then
echo "✅ Migration DLC appliquée avec succès"
else
echo "❌ Erreur lors de la migration"
exit 1
fi
# Redémarrer l'application
echo "🔄 Redémarrage de l'application..."
docker-compose restart logiflow
if [ $? -eq 0 ]; then
echo "✅ Application redémarrée"
else
echo "❌ Erreur lors du redémarrage"
exit 1
fi
echo ""
echo "🎉 CORRECTION TERMINÉE"
echo "L'erreur 'column does not exist' devrait maintenant être résolue"
echo ""
echo "Vérifiez dans les logs que l'API /api/dlc-products fonctionne maintenant"
+20 -1
View File
@@ -20,13 +20,32 @@ export default defineConfig({
alias: {
"@": path.resolve(import.meta.dirname, "client", "src"),
"@shared": path.resolve(import.meta.dirname, "shared"),
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
},
},
// En production, les console.log/debug de debug sont retirés du bundle par
// le minifieur (console.warn et console.error sont conservés). En dev, tout
// reste visible.
esbuild: {
pure: process.env.NODE_ENV === "production" ? ["console.log", "console.debug"] : [],
},
root: path.resolve(import.meta.dirname, "client"),
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
rollupOptions: {
output: {
// Sépare les grosses dépendances stables du code applicatif :
// elles ne changent qu'aux montées de version et restent donc en
// cache navigateur entre deux déploiements, au lieu de faire
// retélécharger 1,7 Mo à chaque mise en production.
manualChunks: {
"vendor-react": ["react", "react-dom", "wouter"],
"vendor-query": ["@tanstack/react-query"],
"vendor-charts": ["recharts"],
"vendor-icons": ["lucide-react"],
},
},
},
},
server: {
fs: {