mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
Add functionality to manage and track customer credit notes
Introduces the "Avoirs" feature, including database schema, API endpoints for CRUD operations, and frontend components for viewing and managing credit notes. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9fcf4b21-eb0c-4e53-a567-e2ce4a6ad869 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/9fcf4b21-eb0c-4e53-a567-e2ce4a6ad869/0C3uBiZ
This commit is contained in:
1 parent
e75fa65945
commit
f3565df01f
6 files changed
+1069
-3
No files matched your search
@@ -21,6 +21,7 @@ import BackupManager from "@/pages/BackupManager";
|
||||
import Utilities from "@/pages/Utilities";
|
||||
import Tasks from "@/pages/Tasks";
|
||||
import SavTickets from "@/pages/SavTickets";
|
||||
import Avoirs from "@/pages/Avoirs";
|
||||
import WeatherSettings from "@/pages/WeatherSettings";
|
||||
import Layout from "@/components/Layout";
|
||||
|
||||
@@ -96,6 +97,7 @@ function RouterProduction() {
|
||||
<Route path="/utilities" component={Utilities} />
|
||||
<Route path="/tasks" component={Tasks} />
|
||||
<Route path="/sav" component={SavTickets} />
|
||||
<Route path="/avoirs" component={Avoirs} />
|
||||
|
||||
{/* Routes de compatibilité - redirection vers utilities */}
|
||||
<Route path="/backup" component={Utilities} />
|
||||
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
Settings,
|
||||
Wrench,
|
||||
FileUp,
|
||||
Send
|
||||
Send,
|
||||
Receipt
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Sidebar() {
|
||||
@@ -284,6 +285,12 @@ export default function Sidebar() {
|
||||
icon: Wrench,
|
||||
roles: ["admin", "directeur", "manager", "employee"]
|
||||
},
|
||||
{
|
||||
path: "/avoirs",
|
||||
label: "Avoirs",
|
||||
icon: Receipt,
|
||||
roles: ["admin", "directeur", "manager"]
|
||||
},
|
||||
];
|
||||
|
||||
const adminItems = [
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search, FileText, CheckCircle, AlertCircle, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
// Types
|
||||
interface Avoir {
|
||||
id: number;
|
||||
supplierId: number;
|
||||
groupId: number;
|
||||
invoiceReference: string;
|
||||
amount: number;
|
||||
comment?: string;
|
||||
commercialProcessed: boolean;
|
||||
status: 'En attente de demande' | 'Demandé' | 'Reçu';
|
||||
webhookSent: boolean;
|
||||
nocodbVerified: boolean;
|
||||
nocodbVerifiedAt?: Date;
|
||||
processedAt?: Date;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
supplier: {
|
||||
id: number;
|
||||
name: string;
|
||||
contact?: string;
|
||||
};
|
||||
group: {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
creator: {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Schema for form validation
|
||||
const avoirSchema = z.object({
|
||||
supplierId: z.number().min(1, "Veuillez sélectionner un fournisseur"),
|
||||
groupId: z.number().min(1, "Veuillez sélectionner un magasin"),
|
||||
invoiceReference: z.string().min(1, "La référence facture est requise"),
|
||||
amount: z.number().min(0.01, "Le montant doit être supérieur à 0"),
|
||||
comment: z.string().optional(),
|
||||
commercialProcessed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type AvoirFormData = z.infer<typeof avoirSchema>;
|
||||
|
||||
export default function Avoirs() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [selectedStoreId, setSelectedStoreId] = useState<string>("all");
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch user profile
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
|
||||
// Fetch groups for store filter
|
||||
const { data: groups = [] } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Fetch suppliers
|
||||
const { data: suppliers = [] } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Fetch avoirs
|
||||
const { data: avoirs = [], isLoading } = useQuery<Avoir[]>({
|
||||
queryKey: ['/api/avoirs', selectedStoreId],
|
||||
queryFn: () => apiRequest(`/api/avoirs${selectedStoreId !== 'all' ? `?storeId=${selectedStoreId}` : ''}`),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Create avoir mutation
|
||||
const createAvoirMutation = useMutation({
|
||||
mutationFn: async (data: AvoirFormData) => {
|
||||
const response = await fetch('/api/avoirs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create avoir');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/avoirs'] });
|
||||
setIsCreateDialogOpen(false);
|
||||
toast({
|
||||
title: "Avoir créé",
|
||||
description: "L'avoir a été créé avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de l'avoir",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize form
|
||||
const form = useForm<AvoirFormData>({
|
||||
resolver: zodResolver(avoirSchema),
|
||||
defaultValues: {
|
||||
supplierId: 0,
|
||||
groupId: 0,
|
||||
invoiceReference: "",
|
||||
amount: 0,
|
||||
comment: "",
|
||||
commercialProcessed: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = (data: AvoirFormData) => {
|
||||
createAvoirMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Filter avoirs based on search term
|
||||
const filteredAvoirs = avoirs.filter(avoir =>
|
||||
avoir.invoiceReference.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
avoir.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
avoir.comment?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(avoir.creator.firstName && avoir.creator.lastName
|
||||
? `${avoir.creator.firstName} ${avoir.creator.lastName}`.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
: avoir.creator.username.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// Status badge variant
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Reçu':
|
||||
return 'default';
|
||||
case 'Demandé':
|
||||
return 'secondary';
|
||||
case 'En attente de demande':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// Status icon
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Reçu':
|
||||
return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||
case 'Demandé':
|
||||
return <Clock className="h-4 w-4 text-blue-600" />;
|
||||
case 'En attente de demande':
|
||||
return <AlertCircle className="h-4 w-4 text-orange-600" />;
|
||||
default:
|
||||
return <FileText className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <div>Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Avoirs</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestion des demandes d'avoirs et de remboursements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nouvel avoir
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un nouvel avoir</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remplissez les informations pour créer une nouvelle demande d'avoir.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplierId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fournisseur</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner un fournisseur" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{suppliers.map((supplier) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Magasin</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner un magasin" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id.toString()}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
{group.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="invoiceReference"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Référence facture</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Numéro de facture" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Montant (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Commentaire</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Raison de l'avoir, détails..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="commercialProcessed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Avoir fait par commercial
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createAvoirMutation.isPending}
|
||||
>
|
||||
{createAvoirMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Rechercher par référence, fournisseur, commentaire..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<Select value={selectedStoreId} onValueChange={setSelectedStoreId}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Tous les magasins" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les magasins</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id.toString()}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
{group.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avoirs List */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-4/5"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : filteredAvoirs.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun avoir</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Commencez par créer votre premier avoir.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAvoirs.map((avoir) => (
|
||||
<Card key={avoir.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
{getStatusIcon(avoir.status)}
|
||||
<span className="ml-2">#{avoir.invoiceReference}</span>
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(avoir.status)}>
|
||||
{avoir.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: avoir.group.color }}
|
||||
/>
|
||||
{avoir.group.name}
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Fournisseur:</span>
|
||||
<span className="font-medium">{avoir.supplier.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Montant:</span>
|
||||
<span className="font-bold text-lg">{avoir.amount.toFixed(2)} €</span>
|
||||
</div>
|
||||
|
||||
{avoir.comment && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
"{avoir.comment}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Par {avoir.creator.firstName && avoir.creator.lastName
|
||||
? `${avoir.creator.firstName} ${avoir.creator.lastName}`
|
||||
: avoir.creator.username
|
||||
}
|
||||
</span>
|
||||
<span>
|
||||
{format(new Date(avoir.createdAt), "dd/MM/yyyy", { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{avoir.commercialProcessed && (
|
||||
<div className="flex items-center text-xs text-green-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Traité par commercial
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{avoir.webhookSent && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Webhook envoyé
|
||||
</Badge>
|
||||
)}
|
||||
{avoir.nocodbVerified && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Vérifié NocoDB
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+252
-1
@@ -41,8 +41,10 @@ import {
|
||||
insertWeatherDataSchema,
|
||||
insertWeatherSettingsSchema,
|
||||
insertWebhookBapConfigSchema,
|
||||
insertAvoirSchema,
|
||||
users, groups, userGroups, suppliers, orders, deliveries, publicities, publicityParticipations,
|
||||
customerOrders, nocodbConfig, dlcProducts, tasks, invoiceVerificationCache, dashboardMessages, webhookBapConfig
|
||||
customerOrders, nocodbConfig, dlcProducts, tasks, invoiceVerificationCache, dashboardMessages, webhookBapConfig,
|
||||
avoirs
|
||||
} from "@shared/schema";
|
||||
import { hasPermission } from "@shared/permissions";
|
||||
import { z } from "zod";
|
||||
@@ -2336,6 +2338,255 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Avoir routes
|
||||
app.get('/api/avoirs', requireModulePermission('avoir', 'view'), 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 { storeId } = req.query;
|
||||
let groupIds: number[] | undefined;
|
||||
|
||||
if (user.role === 'admin' || user.role === 'directeur') {
|
||||
groupIds = storeId ? [parseInt(storeId as string)] : undefined;
|
||||
} else {
|
||||
// Managers can only see their group's avoirs
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (storeId && userGroupIds.includes(parseInt(storeId as string))) {
|
||||
groupIds = [parseInt(storeId as string)];
|
||||
} else if (!storeId) {
|
||||
groupIds = userGroupIds;
|
||||
} else {
|
||||
return res.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 Avoirs API called with:', { groupIds, userRole: user.role });
|
||||
const avoirs = await storage.getAvoirs(groupIds);
|
||||
console.log('📋 Avoirs returned:', avoirs.length, 'items');
|
||||
res.json(avoirs);
|
||||
} catch (error) {
|
||||
console.error("Error fetching avoirs:", error);
|
||||
res.status(500).json({ message: "Failed to fetch avoirs" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/avoirs/:id', requireModulePermission('avoir', 'view'), 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 id = parseInt(req.params.id);
|
||||
const avoir = await storage.getAvoir(id);
|
||||
|
||||
if (!avoir) {
|
||||
return res.status(404).json({ message: "Avoir not found" });
|
||||
}
|
||||
|
||||
// Check if user has access to this avoir's group
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (!userGroupIds.includes(avoir.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(avoir);
|
||||
} catch (error) {
|
||||
console.error("Error fetching avoir:", error);
|
||||
res.status(500).json({ message: "Failed to fetch avoir" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/avoirs', requireModulePermission('avoir', 'create'), 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" });
|
||||
}
|
||||
|
||||
// Validate data with Zod schema
|
||||
const validatedData = insertAvoirSchema.parse({
|
||||
...req.body,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
// Check if user has access to the specified group
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (!userGroupIds.includes(validatedData.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied to this group" });
|
||||
}
|
||||
}
|
||||
|
||||
const avoir = await storage.createAvoir(validatedData);
|
||||
console.log('✅ Avoir created:', avoir.id, 'by user:', user.id);
|
||||
|
||||
// Send webhook after avoir creation
|
||||
try {
|
||||
const group = await storage.getGroup(avoir.groupId);
|
||||
if (group && group.webhookUrl) {
|
||||
const webhookData = {
|
||||
type: "Avoir",
|
||||
avoirId: avoir.id,
|
||||
invoiceReference: avoir.invoiceReference,
|
||||
amount: avoir.amount,
|
||||
supplierName: validatedData.supplierId ? (await storage.getSupplier(validatedData.supplierId))?.name : "Unknown",
|
||||
groupName: group.name,
|
||||
comment: avoir.comment || "",
|
||||
commercialProcessed: avoir.commercialProcessed,
|
||||
createdBy: user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username,
|
||||
createdAt: avoir.createdAt
|
||||
};
|
||||
|
||||
const webhookResponse = await fetch(group.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(webhookData)
|
||||
});
|
||||
|
||||
if (webhookResponse.ok) {
|
||||
await storage.updateAvoirWebhookStatus(avoir.id, true);
|
||||
console.log('✅ Avoir webhook sent successfully:', avoir.id);
|
||||
} else {
|
||||
console.error('❌ Failed to send avoir webhook:', webhookResponse.status);
|
||||
}
|
||||
}
|
||||
} catch (webhookError) {
|
||||
console.error('❌ Error sending avoir webhook:', webhookError);
|
||||
}
|
||||
|
||||
res.json(avoir);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Validation error", errors: error.errors });
|
||||
}
|
||||
console.error("Error creating avoir:", error);
|
||||
res.status(500).json({ message: "Failed to create avoir" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/avoirs/:id', requireModulePermission('avoir', 'edit'), 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 id = parseInt(req.params.id);
|
||||
const existingAvoir = await storage.getAvoir(id);
|
||||
|
||||
if (!existingAvoir) {
|
||||
return res.status(404).json({ message: "Avoir not found" });
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (!userGroupIds.includes(existingAvoir.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAvoir = await storage.updateAvoir(id, req.body);
|
||||
console.log('✅ Avoir updated:', id, 'by user:', user.id);
|
||||
res.json(updatedAvoir);
|
||||
} catch (error) {
|
||||
console.error("Error updating avoir:", error);
|
||||
res.status(500).json({ message: "Failed to update avoir" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/avoirs/:id', requireModulePermission('avoir', 'delete'), 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 id = parseInt(req.params.id);
|
||||
const avoir = await storage.getAvoir(id);
|
||||
|
||||
if (!avoir) {
|
||||
return res.status(404).json({ message: "Avoir not found" });
|
||||
}
|
||||
|
||||
// Check permissions (only admin and directeur can delete)
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
return res.status(403).json({ message: "Insufficient permissions to delete avoirs" });
|
||||
}
|
||||
|
||||
// For directeur, check group access
|
||||
if (user.role === 'directeur') {
|
||||
const userGroupIds = user.userGroups.map(ug => ug.groupId);
|
||||
if (!userGroupIds.includes(avoir.groupId)) {
|
||||
return res.status(403).json({ message: "Access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
await storage.deleteAvoir(id);
|
||||
console.log('✅ Avoir deleted:', id, 'by user:', user.id);
|
||||
res.json({ message: "Avoir deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting avoir:", error);
|
||||
res.status(500).json({ message: "Failed to delete avoir" });
|
||||
}
|
||||
});
|
||||
|
||||
// Avoir status update routes
|
||||
app.put('/api/avoirs/:id/webhook-status', requireModulePermission('avoir', 'edit'), 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 id = parseInt(req.params.id);
|
||||
const { webhookSent } = req.body;
|
||||
|
||||
// Only allow admin and directeur to update webhook status
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
await storage.updateAvoirWebhookStatus(id, webhookSent);
|
||||
res.json({ message: "Webhook status updated successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error updating avoir webhook status:", error);
|
||||
res.status(500).json({ message: "Failed to update webhook status" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/avoirs/:id/nocodb-verification', requireModulePermission('avoir', 'edit'), 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 id = parseInt(req.params.id);
|
||||
const { verified } = req.body;
|
||||
|
||||
// Only allow admin and directeur to update verification status
|
||||
if (user.role !== 'admin' && user.role !== 'directeur') {
|
||||
return res.status(403).json({ message: "Insufficient permissions" });
|
||||
}
|
||||
|
||||
await storage.updateAvoirNocodbVerification(id, verified);
|
||||
console.log('✅ Avoir NocoDB verification updated:', id, 'verified:', verified, 'by user:', user.id);
|
||||
res.json({ message: "NocoDB verification status updated successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error updating avoir NocoDB verification:", error);
|
||||
res.status(500).json({ message: "Failed to update verification status" });
|
||||
}
|
||||
});
|
||||
|
||||
// Statistics routes
|
||||
app.get('/api/stats/monthly', isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
|
||||
+227
-1
@@ -69,6 +69,10 @@ import {
|
||||
webhookBapConfig,
|
||||
type WebhookBapConfig,
|
||||
type InsertWebhookBapConfig,
|
||||
avoirs,
|
||||
type Avoir,
|
||||
type InsertAvoir,
|
||||
type AvoirWithRelations,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne } from "drizzle-orm";
|
||||
@@ -197,6 +201,15 @@ export interface IStorage {
|
||||
getAnnouncement(id: number): Promise<AnnouncementWithRelations | undefined>;
|
||||
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<AnnouncementWithRelations>;
|
||||
deleteAnnouncement(id: number): Promise<boolean>;
|
||||
|
||||
// Avoir operations
|
||||
getAvoirs(groupIds?: number[]): Promise<AvoirWithRelations[]>;
|
||||
getAvoir(id: number): Promise<AvoirWithRelations | undefined>;
|
||||
createAvoir(avoir: InsertAvoir): Promise<Avoir>;
|
||||
updateAvoir(id: number, avoir: Partial<InsertAvoir>): Promise<Avoir>;
|
||||
deleteAvoir(id: number): Promise<void>;
|
||||
updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise<void>;
|
||||
updateAvoirNocodbVerification(id: number, verified: boolean): Promise<void>;
|
||||
|
||||
// SAV operations
|
||||
getSavTickets(filters?: {
|
||||
@@ -2108,6 +2121,134 @@ export class DatabaseStorage implements IStorage {
|
||||
return await announcementStorage.deleteAnnouncement(id);
|
||||
}
|
||||
|
||||
// Avoir operations
|
||||
async getAvoirs(groupIds?: number[]): Promise<AvoirWithRelations[]> {
|
||||
let query = db
|
||||
.select({
|
||||
id: avoirs.id,
|
||||
supplierId: avoirs.supplierId,
|
||||
groupId: avoirs.groupId,
|
||||
invoiceReference: avoirs.invoiceReference,
|
||||
amount: avoirs.amount,
|
||||
comment: avoirs.comment,
|
||||
commercialProcessed: avoirs.commercialProcessed,
|
||||
status: avoirs.status,
|
||||
webhookSent: avoirs.webhookSent,
|
||||
nocodbVerified: avoirs.nocodbVerified,
|
||||
nocodbVerifiedAt: avoirs.nocodbVerifiedAt,
|
||||
processedAt: avoirs.processedAt,
|
||||
createdBy: avoirs.createdBy,
|
||||
createdAt: avoirs.createdAt,
|
||||
updatedAt: avoirs.updatedAt,
|
||||
supplier: suppliers,
|
||||
group: groups,
|
||||
creator: {
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
username: users.username,
|
||||
email: users.email
|
||||
}
|
||||
})
|
||||
.from(avoirs)
|
||||
.leftJoin(suppliers, eq(avoirs.supplierId, suppliers.id))
|
||||
.leftJoin(groups, eq(avoirs.groupId, groups.id))
|
||||
.leftJoin(users, eq(avoirs.createdBy, users.id));
|
||||
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
query = query.where(inArray(avoirs.groupId, groupIds));
|
||||
}
|
||||
|
||||
const results = await query.orderBy(desc(avoirs.createdAt));
|
||||
|
||||
return results.map((result: any) => ({
|
||||
...result,
|
||||
supplier: result.supplier!,
|
||||
group: result.group!,
|
||||
creator: result.creator!,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAvoir(id: number): Promise<AvoirWithRelations | undefined> {
|
||||
const [result] = await db
|
||||
.select({
|
||||
id: avoirs.id,
|
||||
supplierId: avoirs.supplierId,
|
||||
groupId: avoirs.groupId,
|
||||
invoiceReference: avoirs.invoiceReference,
|
||||
amount: avoirs.amount,
|
||||
comment: avoirs.comment,
|
||||
commercialProcessed: avoirs.commercialProcessed,
|
||||
status: avoirs.status,
|
||||
webhookSent: avoirs.webhookSent,
|
||||
nocodbVerified: avoirs.nocodbVerified,
|
||||
nocodbVerifiedAt: avoirs.nocodbVerifiedAt,
|
||||
processedAt: avoirs.processedAt,
|
||||
createdBy: avoirs.createdBy,
|
||||
createdAt: avoirs.createdAt,
|
||||
updatedAt: avoirs.updatedAt,
|
||||
supplier: suppliers,
|
||||
group: groups,
|
||||
creator: {
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
username: users.username,
|
||||
email: users.email
|
||||
}
|
||||
})
|
||||
.from(avoirs)
|
||||
.leftJoin(suppliers, eq(avoirs.supplierId, suppliers.id))
|
||||
.leftJoin(groups, eq(avoirs.groupId, groups.id))
|
||||
.leftJoin(users, eq(avoirs.createdBy, users.id))
|
||||
.where(eq(avoirs.id, id));
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
return {
|
||||
...result,
|
||||
supplier: result.supplier!,
|
||||
group: result.group!,
|
||||
creator: result.creator!,
|
||||
};
|
||||
}
|
||||
|
||||
async createAvoir(avoirData: InsertAvoir): Promise<Avoir> {
|
||||
const [avoir] = await db.insert(avoirs).values(avoirData).returning();
|
||||
return avoir;
|
||||
}
|
||||
|
||||
async updateAvoir(id: number, avoirData: Partial<InsertAvoir>): Promise<Avoir> {
|
||||
const [avoir] = await db
|
||||
.update(avoirs)
|
||||
.set({ ...avoirData, updatedAt: new Date() })
|
||||
.where(eq(avoirs.id, id))
|
||||
.returning();
|
||||
return avoir;
|
||||
}
|
||||
|
||||
async deleteAvoir(id: number): Promise<void> {
|
||||
await db.delete(avoirs).where(eq(avoirs.id, id));
|
||||
}
|
||||
|
||||
async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise<void> {
|
||||
await db
|
||||
.update(avoirs)
|
||||
.set({ webhookSent, updatedAt: new Date() })
|
||||
.where(eq(avoirs.id, id));
|
||||
}
|
||||
|
||||
async updateAvoirNocodbVerification(id: number, verified: boolean): Promise<void> {
|
||||
await db
|
||||
.update(avoirs)
|
||||
.set({
|
||||
nocodbVerified: verified,
|
||||
nocodbVerifiedAt: verified ? new Date() : null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(avoirs.id, id));
|
||||
}
|
||||
|
||||
// SAV operations
|
||||
async getSavTickets(filters?: {
|
||||
groupIds?: number[];
|
||||
@@ -2441,7 +2582,7 @@ export class MemStorage implements IStorage {
|
||||
private dlcProducts = new Map<number, DlcProduct>();
|
||||
private tasks = new Map<number, Task>();
|
||||
private announcements = new Map<number, Announcement>();
|
||||
private publicities = new Map<number, Publicity>();
|
||||
private avoirs = new Map<number, Avoir>();
|
||||
private savTickets = new Map<number, SavTicket>();
|
||||
private savTicketHistories = new Map<number, SavTicketHistory[]>();
|
||||
private invoiceVerificationCache = new Map<string, InvoiceVerificationCache>();
|
||||
@@ -2456,6 +2597,7 @@ export class MemStorage implements IStorage {
|
||||
dlcProduct: 1,
|
||||
task: 1,
|
||||
announcement: 1,
|
||||
avoir: 1,
|
||||
savTicket: 1,
|
||||
savTicketHistory: 1,
|
||||
};
|
||||
@@ -4165,6 +4307,90 @@ export class MemStorage implements IStorage {
|
||||
);
|
||||
return await announcementStorage.deleteAnnouncement(id);
|
||||
}
|
||||
|
||||
// Avoir operations
|
||||
async getAvoirs(groupIds?: number[]): Promise<AvoirWithRelations[]> {
|
||||
let avoirs = Array.from(this.avoirs.values());
|
||||
|
||||
// Filter by groups if provided
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
avoirs = avoirs.filter(avoir => groupIds.includes(avoir.groupId));
|
||||
}
|
||||
|
||||
return avoirs
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map(avoir => ({
|
||||
...avoir,
|
||||
supplier: this.suppliers.get(avoir.supplierId)!,
|
||||
group: this.groups.get(avoir.groupId)!,
|
||||
creator: this.users.get(avoir.createdBy)!,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAvoir(id: number): Promise<AvoirWithRelations | undefined> {
|
||||
const avoir = this.avoirs.get(id);
|
||||
if (!avoir) return undefined;
|
||||
|
||||
return {
|
||||
...avoir,
|
||||
supplier: this.suppliers.get(avoir.supplierId)!,
|
||||
group: this.groups.get(avoir.groupId)!,
|
||||
creator: this.users.get(avoir.createdBy)!,
|
||||
};
|
||||
}
|
||||
|
||||
async createAvoir(avoirData: InsertAvoir): Promise<Avoir> {
|
||||
const id = this.idCounters.avoir++;
|
||||
const avoir: Avoir = {
|
||||
id,
|
||||
...avoirData,
|
||||
status: 'En attente de demande',
|
||||
webhookSent: false,
|
||||
nocodbVerified: false,
|
||||
nocodbVerifiedAt: null,
|
||||
processedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.avoirs.set(id, avoir);
|
||||
return avoir;
|
||||
}
|
||||
|
||||
async updateAvoir(id: number, avoirData: Partial<InsertAvoir>): Promise<Avoir> {
|
||||
const existingAvoir = this.avoirs.get(id);
|
||||
if (!existingAvoir) throw new Error('Avoir not found');
|
||||
|
||||
const updatedAvoir = {
|
||||
...existingAvoir,
|
||||
...avoirData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.avoirs.set(id, updatedAvoir);
|
||||
return updatedAvoir;
|
||||
}
|
||||
|
||||
async deleteAvoir(id: number): Promise<void> {
|
||||
this.avoirs.delete(id);
|
||||
}
|
||||
|
||||
async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise<void> {
|
||||
const avoir = this.avoirs.get(id);
|
||||
if (avoir) {
|
||||
avoir.webhookSent = webhookSent;
|
||||
avoir.updatedAt = new Date();
|
||||
this.avoirs.set(id, avoir);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAvoirNocodbVerification(id: number, verified: boolean): Promise<void> {
|
||||
const avoir = this.avoirs.get(id);
|
||||
if (avoir) {
|
||||
avoir.nocodbVerified = verified;
|
||||
avoir.nocodbVerifiedAt = verified ? new Date() : null;
|
||||
avoir.updatedAt = new Date();
|
||||
this.avoirs.set(id, avoir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use MemStorage in development, DatabaseStorage in production
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Script de création de la table avoirs pour la production
|
||||
-- Exécuter ce script sur votre serveur PostgreSQL privé
|
||||
|
||||
CREATE TABLE IF NOT EXISTS avoirs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
invoice_reference VARCHAR(255) NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
comment TEXT,
|
||||
commercial_processed BOOLEAN DEFAULT FALSE,
|
||||
status VARCHAR(50) DEFAULT 'En attente de demande' CHECK (status IN ('En attente de demande', 'Demandé', 'Reçu')),
|
||||
webhook_sent BOOLEAN DEFAULT FALSE,
|
||||
nocodb_verified BOOLEAN DEFAULT FALSE,
|
||||
nocodb_verified_at TIMESTAMP,
|
||||
processed_at TIMESTAMP,
|
||||
created_by VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index pour améliorer les performances
|
||||
CREATE INDEX IF NOT EXISTS idx_avoirs_supplier_id ON avoirs(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avoirs_group_id ON avoirs(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avoirs_created_by ON avoirs(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_avoirs_status ON avoirs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_avoirs_created_at ON avoirs(created_at DESC);
|
||||
|
||||
-- Trigger pour mettre à jour automatically updated_at
|
||||
CREATE OR REPLACE FUNCTION update_avoirs_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
DROP TRIGGER IF EXISTS update_avoirs_updated_at_trigger ON avoirs;
|
||||
CREATE TRIGGER update_avoirs_updated_at_trigger
|
||||
BEFORE UPDATE ON avoirs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_avoirs_updated_at();
|
||||
|
||||
-- Commentaires pour documenter la table
|
||||
COMMENT ON TABLE avoirs IS 'Table pour gérer les avoirs (credit notes/refunds) avec webhook et vérification NocoDB';
|
||||
COMMENT ON COLUMN avoirs.invoice_reference IS 'Référence de la facture pour laquelle l''avoir est demandé';
|
||||
COMMENT ON COLUMN avoirs.amount IS 'Montant de l''avoir en euros';
|
||||
COMMENT ON COLUMN avoirs.commercial_processed IS 'Indique si l''avoir a été traité par le commercial';
|
||||
COMMENT ON COLUMN avoirs.webhook_sent IS 'Indique si le webhook a été envoyé avec succès';
|
||||
COMMENT ON COLUMN avoirs.nocodb_verified IS 'Indique si l''avoir a été vérifié dans NocoDB';
|
||||
Reference in new issue
Block a user