Merge pull request #360 from R0m1k3/1.1

1.1
This commit is contained in:
LogiFlow authored and GitHub committed 2025-09-08 15:48:48 +02:00
commit 2c61713b74
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} />
+8 -1
View File
@@ -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 = [
+530
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: []
}
};
+61
View File
@@ -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;
+50
View File
@@ -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';