mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-14 02:38:58 +02:00
8 files changed
+1140
-4
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
|
||||
|
||||
+10
-1
@@ -12,7 +12,8 @@ export type Module =
|
||||
| 'publicity'
|
||||
| 'customer-orders'
|
||||
| 'dlc'
|
||||
| 'tasks';
|
||||
| 'tasks'
|
||||
| 'avoir';
|
||||
|
||||
// Définition des permissions par module et par rôle
|
||||
const PERMISSIONS: Record<Module, Record<Role, Permission[]>> = {
|
||||
@@ -86,6 +87,14 @@ const PERMISSIONS: Record<Module, Record<Role, Permission[]>> = {
|
||||
directeur: ['view', 'create', 'edit', 'delete', 'validate'],
|
||||
manager: ['view', 'create', 'edit', 'validate'],
|
||||
employee: ['view']
|
||||
},
|
||||
|
||||
// Avoirs - Admin/Directeur tout, Manager create + view seulement, Employé pas d'accès
|
||||
avoir: {
|
||||
admin: ['view', 'create', 'edit', 'delete', 'validate'],
|
||||
directeur: ['view', 'create', 'edit', 'delete', 'validate'],
|
||||
manager: ['view', 'create'],
|
||||
employee: []
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -262,6 +262,25 @@ export const tasks = pgTable("tasks", {
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
// Avoirs table
|
||||
export const avoirs = pgTable("avoirs", {
|
||||
id: serial("id").primaryKey(),
|
||||
supplierId: integer("supplier_id").notNull(), // Fournisseur
|
||||
groupId: integer("group_id").notNull(), // Magasin/groupe associé - OBLIGATOIRE pour filtrage
|
||||
invoiceReference: varchar("invoice_reference", { length: 255 }).notNull(), // Référence facture
|
||||
amount: decimal("amount", { precision: 10, scale: 2 }).notNull(), // Montant
|
||||
comment: text("comment"), // Commentaire (optionnel)
|
||||
commercialProcessed: boolean("commercial_processed").default(false), // Avoir fait par commercial
|
||||
status: varchar("status", { length: 50 }).notNull().default("En attente de demande"), // En attente de demande, Demandé, Reçu
|
||||
webhookSent: boolean("webhook_sent").default(false), // Webhook envoyé
|
||||
nocodbVerified: boolean("nocodb_verified").default(false), // Vérifié dans NocoDB (coche verte)
|
||||
nocodbVerifiedAt: timestamp("nocodb_verified_at"), // Date de vérification
|
||||
processedAt: timestamp("processed_at"), // Date de passage au statut "Reçu"
|
||||
createdBy: varchar("created_by").notNull(), // Utilisateur créateur
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
// Dashboard Messages - Utilise la table existante DASHBOARD_MESSAGES
|
||||
export const dashboardMessages = pgTable("dashboard_messages", {
|
||||
id: serial("id").primaryKey(),
|
||||
@@ -287,6 +306,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
createdTasks: many(tasks),
|
||||
assignedTasks: many(tasks),
|
||||
createdAnnouncements: many(dashboardMessages),
|
||||
createdAvoirs: many(avoirs),
|
||||
}));
|
||||
|
||||
export const groupsRelations = relations(groups, ({ one, many }) => ({
|
||||
@@ -298,6 +318,7 @@ export const groupsRelations = relations(groups, ({ one, many }) => ({
|
||||
dlcProducts: many(dlcProducts),
|
||||
tasks: many(tasks),
|
||||
announcements: many(dashboardMessages),
|
||||
avoirs: many(avoirs),
|
||||
}));
|
||||
|
||||
export const userGroupsRelations = relations(userGroups, ({ one }) => ({
|
||||
@@ -315,6 +336,7 @@ export const suppliersRelations = relations(suppliers, ({ many }) => ({
|
||||
orders: many(orders),
|
||||
deliveries: many(deliveries),
|
||||
dlcProducts: many(dlcProducts),
|
||||
avoirs: many(avoirs),
|
||||
}));
|
||||
|
||||
export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||
@@ -418,6 +440,21 @@ export const tasksRelations = relations(tasks, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const avoirsRelations = relations(avoirs, ({ one }) => ({
|
||||
supplier: one(suppliers, {
|
||||
fields: [avoirs.supplierId],
|
||||
references: [suppliers.id],
|
||||
}),
|
||||
group: one(groups, {
|
||||
fields: [avoirs.groupId],
|
||||
references: [groups.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [avoirs.createdBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const dashboardMessagesRelations = relations(dashboardMessages, ({ one }) => ({
|
||||
author: one(users, {
|
||||
fields: [dashboardMessages.createdBy],
|
||||
@@ -538,6 +575,22 @@ export const insertTaskSchema = createInsertSchema(tasks).omit({
|
||||
dueDate: z.coerce.date().optional().nullable(), // Date d'échéance (optionnel)
|
||||
});
|
||||
|
||||
export const insertAvoirSchema = createInsertSchema(avoirs).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
webhookSent: true,
|
||||
nocodbVerified: true,
|
||||
nocodbVerifiedAt: true,
|
||||
processedAt: true,
|
||||
}).extend({
|
||||
status: z.enum(["En attente de demande", "Demandé", "Reçu"]).default("En attente de demande"),
|
||||
amount: z.coerce.number().positive(), // Montant doit être positif
|
||||
invoiceReference: z.string().min(1, "La référence facture est obligatoire"),
|
||||
comment: z.string().optional(),
|
||||
commercialProcessed: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const insertDashboardMessageSchema = createInsertSchema(dashboardMessages).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@@ -601,6 +654,8 @@ export type DlcProductFrontend = Omit<DlcProduct, 'expiryDate'> & { dlcDate: Dat
|
||||
export type InsertDlcProductFrontend = z.infer<typeof insertDlcProductFrontendSchema>;
|
||||
export type Task = typeof tasks.$inferSelect;
|
||||
export type InsertTask = z.infer<typeof insertTaskSchema>;
|
||||
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>;
|
||||
|
||||
@@ -656,6 +711,12 @@ export type TaskWithRelations = Task & {
|
||||
isFutureTask?: boolean; // Pour distinguer les tâches futures (admin/directeur)
|
||||
};
|
||||
|
||||
export type AvoirWithRelations = Avoir & {
|
||||
supplier: Supplier;
|
||||
group: Group;
|
||||
creator: User;
|
||||
};
|
||||
|
||||
// Dashboard Message with relations type
|
||||
export type DashboardMessageWithRelations = DashboardMessage & {
|
||||
author: User;
|
||||
|
||||
@@ -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