Merge pull request #428 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-09-10 09:55:58 +02:00
commit 73af230369
6 files changed
+1612 -259

No files matched your search

@@ -0,0 +1,396 @@
import React, { useState } from "react";
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 { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { safeFormat } from "@/lib/dateUtils";
import { MessageSquare, Plus, Edit2, Trash2, Save, X, AlertCircle, Info, CheckCircle, AlertTriangle } from "lucide-react";
interface ReconciliationCommentsProps {
deliveryId: number;
className?: string;
}
interface ReconciliationComment {
id: number;
content: string;
type: 'info' | 'warning' | 'error' | 'success';
deliveryId: number;
authorId: string;
groupId: number;
createdAt: Date;
updatedAt: Date;
author: {
id: string;
email: string;
role: string;
};
}
const typeColors = {
info: "bg-blue-50 border-blue-200 text-blue-800",
warning: "bg-yellow-50 border-yellow-200 text-yellow-800",
error: "bg-red-50 border-red-200 text-red-800",
success: "bg-green-50 border-green-200 text-green-800",
};
const typeIcons = {
info: Info,
warning: AlertTriangle,
error: AlertCircle,
success: CheckCircle,
};
const typeLabels = {
info: "Information",
warning: "Attention",
error: "Erreur",
success: "Succès",
};
export default function ReconciliationComments({ deliveryId, className = "" }: ReconciliationCommentsProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [newComment, setNewComment] = useState<{
content: string;
type: 'info' | 'warning' | 'error' | 'success';
}>({
content: "",
type: "info",
});
const [editComment, setEditComment] = useState<{
content: string;
type: 'info' | 'warning' | 'error' | 'success';
}>({
content: "",
type: "info",
});
// Récupérer les commentaires
const { data: comments = [], isLoading } = useQuery({
queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`],
queryFn: () => apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`),
enabled: !!deliveryId,
});
// Créer un commentaire
const createCommentMutation = useMutation({
mutationFn: async (data: { content: string; type: string }) => {
return apiRequest(`/api/deliveries/${deliveryId}/reconciliation-comments`, "POST", data);
},
onSuccess: () => {
toast({
title: "Succès",
description: "Commentaire ajouté avec succès",
});
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
setIsAdding(false);
setNewComment({ content: "", type: "info" });
},
onError: (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'ajouter le commentaire",
variant: "destructive",
});
},
});
// Modifier un commentaire
const updateCommentMutation = useMutation({
mutationFn: async ({ id, data }: { id: number; data: { content: string; type: string } }) => {
return apiRequest(`/api/reconciliation-comments/${id}`, "PUT", data);
},
onSuccess: () => {
toast({
title: "Succès",
description: "Commentaire modifié avec succès",
});
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
setEditingId(null);
},
onError: (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible de modifier le commentaire",
variant: "destructive",
});
},
});
// Supprimer un commentaire
const deleteCommentMutation = useMutation({
mutationFn: async (id: number) => {
return apiRequest(`/api/reconciliation-comments/${id}`, "DELETE");
},
onSuccess: () => {
toast({
title: "Succès",
description: "Commentaire supprimé avec succès",
});
queryClient.invalidateQueries({ queryKey: [`/api/deliveries/${deliveryId}/reconciliation-comments`] });
},
onError: (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible de supprimer le commentaire",
variant: "destructive",
});
},
});
const handleAddComment = (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.content.trim()) return;
createCommentMutation.mutate(newComment);
};
const handleEditComment = (comment: ReconciliationComment) => {
setEditingId(comment.id);
setEditComment({
content: comment.content,
type: comment.type,
});
};
const handleSaveEdit = () => {
if (!editComment.content.trim() || !editingId) return;
updateCommentMutation.mutate({
id: editingId,
data: editComment,
});
};
const handleCancelEdit = () => {
setEditingId(null);
setEditComment({ content: "", type: "info" });
};
const handleDeleteComment = (id: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce commentaire ?")) {
deleteCommentMutation.mutate(id);
}
};
if (isLoading) {
return (
<div className={`animate-pulse ${className}`}>
<div className="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div className="h-20 bg-gray-200 rounded"></div>
</div>
);
}
return (
<div className={className}>
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium text-gray-900 flex items-center">
<MessageSquare className="w-4 h-4 mr-2" />
Commentaires de rapprochement
{comments.length > 0 && (
<Badge variant="secondary" className="ml-2">
{comments.length}
</Badge>
)}
</h4>
{!isAdding && (
<Button
size="sm"
variant="outline"
onClick={() => setIsAdding(true)}
>
<Plus className="w-4 h-4 mr-1" />
Ajouter
</Button>
)}
</div>
{/* Formulaire d'ajout */}
{isAdding && (
<Card className="mb-4 border-dashed">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-gray-600">Nouveau commentaire</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddComment} className="space-y-3">
<div>
<Label htmlFor="comment-type">Type</Label>
<Select
value={newComment.type}
onValueChange={(value: any) => setNewComment(prev => ({ ...prev, type: value }))}
>
<SelectTrigger id="comment-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info">Information</SelectItem>
<SelectItem value="warning">Attention</SelectItem>
<SelectItem value="error">Erreur</SelectItem>
<SelectItem value="success">Succès</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="comment-content">Commentaire</Label>
<Textarea
id="comment-content"
value={newComment.content}
onChange={(e) => setNewComment(prev => ({ ...prev, content: e.target.value }))}
placeholder="Saisissez votre commentaire..."
rows={3}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsAdding(false);
setNewComment({ content: "", type: "info" });
}}
>
<X className="w-4 h-4 mr-1" />
Annuler
</Button>
<Button
type="submit"
size="sm"
disabled={!newComment.content.trim() || createCommentMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{createCommentMutation.isPending ? "Ajout..." : "Ajouter"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Liste des commentaires */}
<div className="space-y-3">
{comments.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>Aucun commentaire pour cette livraison</p>
</div>
) : (
comments.map((comment: ReconciliationComment) => {
const TypeIcon = typeIcons[comment.type];
const isEditing = editingId === comment.id;
return (
<Card key={comment.id} className={`border ${typeColors[comment.type]}`}>
<CardContent className="p-4">
{isEditing ? (
<div className="space-y-3">
<div>
<Label htmlFor={`edit-type-${comment.id}`}>Type</Label>
<Select
value={editComment.type}
onValueChange={(value: any) => setEditComment(prev => ({ ...prev, type: value }))}
>
<SelectTrigger id={`edit-type-${comment.id}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info">Information</SelectItem>
<SelectItem value="warning">Attention</SelectItem>
<SelectItem value="error">Erreur</SelectItem>
<SelectItem value="success">Succès</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor={`edit-content-${comment.id}`}>Commentaire</Label>
<Textarea
id={`edit-content-${comment.id}`}
value={editComment.content}
onChange={(e) => setEditComment(prev => ({ ...prev, content: e.target.value }))}
rows={3}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelEdit}
>
<X className="w-4 h-4 mr-1" />
Annuler
</Button>
<Button
type="button"
size="sm"
onClick={handleSaveEdit}
disabled={!editComment.content.trim() || updateCommentMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{updateCommentMutation.isPending ? "Sauvegarde..." : "Sauvegarder"}
</Button>
</div>
</div>
) : (
<div>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2">
<TypeIcon className="w-4 h-4" />
<Badge variant="outline">
{typeLabels[comment.type]}
</Badge>
</div>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={() => handleEditComment(comment)}
className="h-6 w-6 p-0"
>
<Edit2 className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteComment(comment.id)}
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<p className="text-sm mb-2">{comment.content}</p>
<div className="text-xs text-gray-500 flex items-center justify-between">
<span>Par {comment.author.email}</span>
<span>{safeFormat(new Date(comment.createdAt), 'dd/MM/yyyy HH:mm')}</span>
</div>
</div>
)}
</CardContent>
</Card>
);
})
)}
</div>
</div>
);
}
@@ -6,10 +6,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { safeFormat } from "@/lib/dateUtils";
import { FileText, Settings, Check, X } from "lucide-react";
import { FileText, Settings, Check, X, MessageSquare } from "lucide-react";
import ReconciliationComments from "@/components/ReconciliationComments";
interface ReconciliationModalProps {
isOpen: boolean;
@@ -91,7 +93,7 @@ export default function ReconciliationModal({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
{isAutomaticMode ? (
@@ -138,94 +140,118 @@ export default function ReconciliationModal({
<Separator />
{/* Formulaire de rapprochement */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Données BL */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Bon de Livraison (BL)</h4>
<div>
<Label htmlFor="blNumber">N° BL</Label>
<Input
id="blNumber"
value={formData.blNumber}
onChange={(e) => setFormData(prev => ({ ...prev, blNumber: e.target.value }))}
placeholder="Ex: BL-2025-001 (optionnel)"
/>
{/* Onglets pour les données de rapprochement et les commentaires */}
<Tabs defaultValue="reconciliation" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="reconciliation" className="flex items-center space-x-2">
<FileText className="w-4 h-4" />
<span>Données de rapprochement</span>
</TabsTrigger>
<TabsTrigger value="comments" className="flex items-center space-x-2">
<MessageSquare className="w-4 h-4" />
<span>Commentaires</span>
</TabsTrigger>
</TabsList>
<TabsContent value="reconciliation" className="space-y-4">
{/* Formulaire de rapprochement */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Données BL */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Bon de Livraison (BL)</h4>
<div>
<Label htmlFor="blNumber">N° BL</Label>
<Input
id="blNumber"
value={formData.blNumber}
onChange={(e) => setFormData(prev => ({ ...prev, blNumber: e.target.value }))}
placeholder="Ex: BL-2025-001 (optionnel)"
/>
</div>
<div>
<Label htmlFor="blAmount">Montant BL (€)</Label>
<Input
id="blAmount"
type="number"
step="0.01"
value={formData.blAmount}
onChange={(e) => setFormData(prev => ({ ...prev, blAmount: e.target.value }))}
placeholder="0.00"
/>
</div>
</div>
{/* Données Facture */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Facture</h4>
<div>
<Label htmlFor="invoiceReference">Référence Facture</Label>
<Input
id="invoiceReference"
value={formData.invoiceReference}
onChange={(e) => setFormData(prev => ({ ...prev, invoiceReference: e.target.value }))}
placeholder="Ex: FAC-2025-001"
/>
</div>
<div>
<Label htmlFor="invoiceAmount">Montant Facture (€)</Label>
<Input
id="invoiceAmount"
type="number"
step="0.01"
value={formData.invoiceAmount}
onChange={(e) => setFormData(prev => ({ ...prev, invoiceAmount: e.target.value }))}
placeholder="0.00"
/>
</div>
</div>
</div>
<div>
<Label htmlFor="blAmount">Montant BL (€)</Label>
<Input
id="blAmount"
type="number"
step="0.01"
value={formData.blAmount}
onChange={(e) => setFormData(prev => ({ ...prev, blAmount: e.target.value }))}
placeholder="0.00"
/>
{/* Actions */}
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={updateDeliveryMutation.isPending}
>
<X className="w-4 h-4 mr-2" />
Annuler
</Button>
<Button
type="submit"
variant="outline"
disabled={updateDeliveryMutation.isPending}
>
{updateDeliveryMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</div>
</form>
{/* Note d'aide */}
<div className="text-xs text-gray-500 bg-blue-50 p-3 rounded">
<strong>Note :</strong> Tous les champs sont optionnels.
Les données BL et facture sont recommandées pour un suivi précis mais pas obligatoires.
</div>
</TabsContent>
{/* Données Facture */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Facture</h4>
<div>
<Label htmlFor="invoiceReference">Référence Facture</Label>
<Input
id="invoiceReference"
value={formData.invoiceReference}
onChange={(e) => setFormData(prev => ({ ...prev, invoiceReference: e.target.value }))}
placeholder="Ex: FAC-2025-001"
/>
</div>
<div>
<Label htmlFor="invoiceAmount">Montant Facture (€)</Label>
<Input
id="invoiceAmount"
type="number"
step="0.01"
value={formData.invoiceAmount}
onChange={(e) => setFormData(prev => ({ ...prev, invoiceAmount: e.target.value }))}
placeholder="0.00"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={updateDeliveryMutation.isPending}
>
<X className="w-4 h-4 mr-2" />
Annuler
</Button>
<Button
type="submit"
variant="outline"
disabled={updateDeliveryMutation.isPending}
>
{updateDeliveryMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</div>
</div>
</form>
{/* Note d'aide */}
<div className="text-xs text-gray-500 bg-blue-50 p-3 rounded">
<strong>Note :</strong> Tous les champs sont optionnels.
Les données BL et facture sont recommandées pour un suivi précis mais pas obligatoires.
</div>
<TabsContent value="comments" className="space-y-4">
{delivery?.id && (
<ReconciliationComments
deliveryId={delivery.id}
className="min-h-[400px]"
/>
)}
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
+157
View File
@@ -42,6 +42,7 @@ import {
insertWeatherSettingsSchema,
insertWebhookBapConfigSchema,
insertAvoirSchema,
insertReconciliationCommentSchema,
users, groups, userGroups, suppliers, orders, deliveries, publicities, publicityParticipations,
customerOrders, nocodbConfig, dlcProducts, tasks, invoiceVerificationCache, dashboardMessages, webhookBapConfig,
avoirs
@@ -1378,6 +1379,162 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Routes pour les commentaires de rapprochement
// GET - Récupérer les commentaires d'une livraison
app.get('/api/deliveries/:id/reconciliation-comments', 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: "Delivery not found" });
}
// Check permissions
if (!hasPermission(user.role, 'deliveries', 'view')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
// Check group access
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" });
}
}
const comments = await storage.getReconciliationComments(deliveryId);
res.json(comments);
} catch (error) {
console.error("Error fetching reconciliation comments:", error);
res.status(500).json({ message: "Failed to fetch reconciliation comments" });
}
});
// POST - Créer un nouveau commentaire
app.post('/api/deliveries/:id/reconciliation-comments', 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: "Delivery not found" });
}
// Check permissions
if (!hasPermission(user.role, 'deliveries', 'edit')) {
return res.status(403).json({ message: "Insufficient permissions to create comments" });
}
// Check group access
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" });
}
}
const commentData = insertReconciliationCommentSchema.parse({
...req.body,
deliveryId,
authorId: user.id,
groupId: delivery.groupId,
});
const comment = await storage.createReconciliationComment(commentData);
res.status(201).json(comment);
} catch (error) {
console.error("Error creating reconciliation comment:", error);
res.status(500).json({ message: "Failed to create reconciliation comment" });
}
});
// PUT - Modifier un commentaire
app.put('/api/reconciliation-comments/:id', 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 commentId = parseInt(req.params.id);
const comments = await storage.getReconciliationComments(0); // Get all to find the comment
const comment = comments.find(c => c.id === commentId);
if (!comment) {
return res.status(404).json({ message: "Comment not found" });
}
// Check permissions - only author or admin can edit
if (user.role !== 'admin' && comment.authorId !== user.id) {
return res.status(403).json({ message: "Only comment author or admin can edit comments" });
}
// Check group access
if (user.role !== 'admin') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (!userGroupIds.includes(comment.groupId)) {
return res.status(403).json({ message: "Access denied to this group" });
}
}
const updateData = insertReconciliationCommentSchema.partial().parse(req.body);
const updatedComment = await storage.updateReconciliationComment(commentId, updateData);
res.json(updatedComment);
} catch (error) {
console.error("Error updating reconciliation comment:", error);
res.status(500).json({ message: "Failed to update reconciliation comment" });
}
});
// DELETE - Supprimer un commentaire
app.delete('/api/reconciliation-comments/:id', 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 commentId = parseInt(req.params.id);
const comments = await storage.getReconciliationComments(0); // Get all to find the comment
const comment = comments.find(c => c.id === commentId);
if (!comment) {
return res.status(404).json({ message: "Comment not found" });
}
// Check permissions - only author or admin can delete
if (user.role !== 'admin' && comment.authorId !== user.id) {
return res.status(403).json({ message: "Only comment author or admin can delete comments" });
}
// Check group access
if (user.role !== 'admin') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (!userGroupIds.includes(comment.groupId)) {
return res.status(403).json({ message: "Access denied to this group" });
}
}
await storage.deleteReconciliationComment(commentId);
res.json({ message: "Comment deleted successfully" });
} catch (error) {
console.error("Error deleting reconciliation comment:", error);
res.status(500).json({ message: "Failed to delete reconciliation comment" });
}
});
// Route de vérification de facture NocoDB
app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => {
try {
+154
View File
@@ -14,6 +14,7 @@ import {
announcements,
nocodbConfig,
invoiceVerificationCache,
reconciliationComments,
savTickets,
savTicketHistory,
weatherData,
@@ -52,6 +53,9 @@ import {
type Announcement,
type InsertAnnouncement,
type AnnouncementWithRelations,
type ReconciliationComment,
type InsertReconciliationComment,
type ReconciliationCommentWithRelations,
type NocodbConfig,
type InsertNocodbConfig,
type InvoiceVerificationCache,
@@ -248,6 +252,12 @@ export interface IStorage {
getWebhookBapConfig(): Promise<WebhookBapConfig | undefined>;
createWebhookBapConfig(config: InsertWebhookBapConfig): Promise<WebhookBapConfig>;
updateWebhookBapConfig(id: number, config: Partial<InsertWebhookBapConfig>): Promise<WebhookBapConfig>;
// Reconciliation Comments operations
getReconciliationComments(deliveryId: number): Promise<ReconciliationCommentWithRelations[]>;
createReconciliationComment(comment: InsertReconciliationComment): Promise<ReconciliationComment>;
updateReconciliationComment(id: number, comment: Partial<InsertReconciliationComment>): Promise<ReconciliationComment>;
deleteReconciliationComment(id: number): Promise<void>;
}
export class DatabaseStorage implements IStorage {
@@ -2566,6 +2576,83 @@ export class DatabaseStorage implements IStorage {
.returning();
return config;
}
// Reconciliation Comments operations
async getReconciliationComments(deliveryId: number): Promise<ReconciliationCommentWithRelations[]> {
const comments = await db
.select({
id: reconciliationComments.id,
deliveryId: reconciliationComments.deliveryId,
groupId: reconciliationComments.groupId,
content: reconciliationComments.content,
type: reconciliationComments.type,
authorId: reconciliationComments.authorId,
createdAt: reconciliationComments.createdAt,
updatedAt: reconciliationComments.updatedAt,
author: users,
group: groups,
delivery: {
id: deliveries.id,
orderId: deliveries.orderId,
supplierId: deliveries.supplierId,
groupId: deliveries.groupId,
scheduledDate: deliveries.scheduledDate,
deliveredDate: deliveries.deliveredDate,
quantity: deliveries.quantity,
unit: deliveries.unit,
status: deliveries.status,
notes: deliveries.notes,
blNumber: deliveries.blNumber,
blAmount: deliveries.blAmount,
invoiceReference: deliveries.invoiceReference,
invoiceAmount: deliveries.invoiceAmount,
reconciled: deliveries.reconciled,
validatedAt: deliveries.validatedAt,
createdBy: deliveries.createdBy,
createdAt: deliveries.createdAt,
updatedAt: deliveries.updatedAt,
}
})
.from(reconciliationComments)
.leftJoin(users, eq(reconciliationComments.authorId, users.id))
.leftJoin(groups, eq(reconciliationComments.groupId, groups.id))
.leftJoin(deliveries, eq(reconciliationComments.deliveryId, deliveries.id))
.where(eq(reconciliationComments.deliveryId, deliveryId))
.orderBy(desc(reconciliationComments.createdAt));
return comments.map(comment => ({
...comment,
delivery: {
...comment.delivery,
supplier: {} as any, // Will be filled by relations if needed
group: comment.group!,
creator: {} as any, // Will be filled by relations if needed
}
})) as ReconciliationCommentWithRelations[];
}
async createReconciliationComment(commentData: InsertReconciliationComment): Promise<ReconciliationComment> {
const [comment] = await db
.insert(reconciliationComments)
.values(commentData)
.returning();
return comment;
}
async updateReconciliationComment(id: number, commentData: Partial<InsertReconciliationComment>): Promise<ReconciliationComment> {
const [comment] = await db
.update(reconciliationComments)
.set({ ...commentData, updatedAt: new Date() })
.where(eq(reconciliationComments.id, id))
.returning();
return comment;
}
async deleteReconciliationComment(id: number): Promise<void> {
await db
.delete(reconciliationComments)
.where(eq(reconciliationComments.id, id));
}
}
// MemStorage class for development
@@ -2581,6 +2668,7 @@ export class MemStorage implements IStorage {
private customerOrders = new Map<number, CustomerOrder>();
private dlcProducts = new Map<number, DlcProduct>();
private tasks = new Map<number, Task>();
private reconciliationComments = new Map<number, ReconciliationComment>();
private announcements = new Map<number, Announcement>();
private avoirs = new Map<number, Avoir>();
private savTickets = new Map<number, SavTicket>();
@@ -2600,6 +2688,7 @@ export class MemStorage implements IStorage {
avoir: 1,
savTicket: 1,
savTicketHistory: 1,
reconciliationComment: 1,
};
constructor() {
@@ -4391,6 +4480,71 @@ export class MemStorage implements IStorage {
this.avoirs.set(id, avoir);
}
}
// Reconciliation Comments operations
async getReconciliationComments(deliveryId: number): Promise<ReconciliationCommentWithRelations[]> {
const comments = Array.from(this.reconciliationComments.values())
.filter(comment => comment.deliveryId === deliveryId)
.sort((a, b) => new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime());
return comments.map(comment => {
const delivery = this.deliveries.get(comment.deliveryId);
const author = this.users.get(comment.authorId);
const group = this.groups.get(comment.groupId);
if (!delivery || !author || !group) {
throw new Error('Missing related data for reconciliation comment');
}
const supplier = this.suppliers.get(delivery.supplierId);
const creator = this.users.get(delivery.createdBy);
if (!supplier || !creator) {
throw new Error('Missing delivery related data for reconciliation comment');
}
return {
...comment,
delivery: {
...delivery,
supplier,
group,
creator,
},
group,
author,
} as ReconciliationCommentWithRelations;
});
}
async createReconciliationComment(commentData: InsertReconciliationComment): Promise<ReconciliationComment> {
const id = this.idCounters.reconciliationComment++;
const comment: ReconciliationComment = {
id,
...commentData,
createdAt: new Date(),
updatedAt: new Date(),
};
this.reconciliationComments.set(id, comment);
return comment;
}
async updateReconciliationComment(id: number, commentData: Partial<InsertReconciliationComment>): Promise<ReconciliationComment> {
const existingComment = this.reconciliationComments.get(id);
if (!existingComment) throw new Error('Reconciliation comment not found');
const updatedComment = {
...existingComment,
...commentData,
updatedAt: new Date(),
};
this.reconciliationComments.set(id, updatedComment);
return updatedComment;
}
async deleteReconciliationComment(id: number): Promise<void> {
this.reconciliationComments.delete(id);
}
}
// Use MemStorage in development, DatabaseStorage in production
+49 -1
View File
@@ -177,6 +177,18 @@ export const invoiceVerificationCache = pgTable("invoice_verification_cache", {
updatedAt: timestamp("updated_at").defaultNow(),
});
// Commentaires de rapprochement - Système indépendant pour le module de rapprochement
export const reconciliationComments = pgTable("reconciliation_comments", {
id: serial("id").primaryKey(),
deliveryId: integer("delivery_id").notNull(), // Référence vers la livraison concernée
groupId: integer("group_id").notNull(), // Magasin/groupe pour filtrage
content: text("content").notNull(), // Contenu du commentaire
type: varchar("type", { length: 50 }).notNull().default("info"), // info, warning, error, success
authorId: varchar("author_id").notNull(), // Utilisateur auteur du commentaire
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
@@ -307,6 +319,7 @@ export const usersRelations = relations(users, ({ many }) => ({
assignedTasks: many(tasks),
createdAnnouncements: many(dashboardMessages),
createdAvoirs: many(avoirs),
createdReconciliationComments: many(reconciliationComments),
}));
export const groupsRelations = relations(groups, ({ one, many }) => ({
@@ -319,6 +332,7 @@ export const groupsRelations = relations(groups, ({ one, many }) => ({
tasks: many(tasks),
announcements: many(dashboardMessages),
avoirs: many(avoirs),
reconciliationComments: many(reconciliationComments),
}));
export const userGroupsRelations = relations(userGroups, ({ one }) => ({
@@ -355,7 +369,7 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
deliveries: many(deliveries),
}));
export const deliveriesRelations = relations(deliveries, ({ one }) => ({
export const deliveriesRelations = relations(deliveries, ({ one, many }) => ({
order: one(orders, {
fields: [deliveries.orderId],
references: [orders.id],
@@ -372,6 +386,7 @@ export const deliveriesRelations = relations(deliveries, ({ one }) => ({
fields: [deliveries.createdBy],
references: [users.id],
}),
reconciliationComments: many(reconciliationComments),
}));
export const publicitiesRelations = relations(publicities, ({ one, many }) => ({
@@ -469,6 +484,22 @@ export const dashboardMessagesRelations = relations(dashboardMessages, ({ one })
// Alias pour compatibilité
export const announcementsRelations = dashboardMessagesRelations;
// Relations pour les commentaires de rapprochement
export const reconciliationCommentsRelations = relations(reconciliationComments, ({ one }) => ({
delivery: one(deliveries, {
fields: [reconciliationComments.deliveryId],
references: [deliveries.id],
}),
group: one(groups, {
fields: [reconciliationComments.groupId],
references: [groups.id],
}),
author: one(users, {
fields: [reconciliationComments.authorId],
references: [users.id],
}),
}));
// Zod schemas
export const insertUserSchema = createInsertSchema(users).pick({
id: true,
@@ -598,6 +629,14 @@ export const insertDashboardMessageSchema = createInsertSchema(dashboardMessages
export const insertAnnouncementSchema = insertDashboardMessageSchema;
export const insertReconciliationCommentSchema = createInsertSchema(reconciliationComments).omit({
id: true,
createdAt: true,
updatedAt: true,
}).extend({
type: z.enum(["info", "warning", "error", "success"]).default("info"),
});
export const insertNocodbConfigSchema = createInsertSchema(nocodbConfig).omit({
id: true,
createdAt: true,
@@ -658,6 +697,8 @@ export type Avoir = typeof avoirs.$inferSelect;
export type InsertAvoir = z.infer<typeof insertAvoirSchema>;
export type DashboardMessage = typeof dashboardMessages.$inferSelect;
export type InsertDashboardMessage = z.infer<typeof insertDashboardMessageSchema>;
export type ReconciliationComment = typeof reconciliationComments.$inferSelect;
export type InsertReconciliationComment = z.infer<typeof insertReconciliationCommentSchema>;
// Alias pour compatibilité
export type Announcement = DashboardMessage;
@@ -726,6 +767,13 @@ export type DashboardMessageWithRelations = DashboardMessage & {
// Alias pour compatibilité
export type AnnouncementWithRelations = DashboardMessageWithRelations;
// Reconciliation Comment with relations type
export type ReconciliationCommentWithRelations = ReconciliationComment & {
delivery: DeliveryWithRelations;
group: Group;
author: User;
};
// SAV (Service Après-Vente) Tables
export const savTickets = pgTable("sav_tickets", {
id: serial("id").primaryKey(),