mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
Modify the `avoirs` table schema to make `invoice_reference` and `amount` nullable, update the `suppliers` table insertion to use the `contact` field, and provide a comprehensive guide for production deployment. 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/mpVybiP
23 KiB
23 KiB
🚀 SOLUTION COMPLÈTE - MODULE AVOIRS PRODUCTION
🔥 PROBLÈME RÉSOLU
- Erreur
contact_emailsupprimée - Interface React corrigée pour les champs optionnels
- Prêt pour la production
📋 ÉTAPES DE DÉPLOIEMENT
ÉTAPE 1 - Base de données (Exécuter dans votre console PostgreSQL)
-- Corriger la structure de la table avoirs
ALTER TABLE avoirs ALTER COLUMN invoice_reference DROP NOT NULL;
ALTER TABLE avoirs ALTER COLUMN amount DROP NOT NULL;
-- Vérifier la correction
SELECT column_name, is_nullable
FROM information_schema.columns
WHERE table_name = 'avoirs'
AND column_name IN ('invoice_reference', 'amount');
ÉTAPE 2 - Fichiers à remplacer sur votre serveur
A) shared/schema.ts (section avoirs uniquement)
Remplacez la section avoirs par :
export const avoirs = pgTable("avoirs", {
id: serial("id").primaryKey(),
supplierId: integer("supplier_id").notNull(),
groupId: integer("group_id").notNull(),
invoiceReference: varchar("invoice_reference"), // OPTIONNEL
amount: decimal("amount", { precision: 10, scale: 2 }), // OPTIONNEL
comment: text("comment"),
commercialProcessed: boolean("commercial_processed").default(false),
status: varchar("status").notNull().default("En attente de demande"),
webhookSent: boolean("webhook_sent").default(false),
nocodbVerified: boolean("nocodb_verified").default(false),
nocodbVerifiedAt: timestamp("nocodb_verified_at"),
processedAt: timestamp("processed_at"),
createdBy: varchar("created_by").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const insertAvoirSchema = createInsertSchema(avoirs).omit({
id: true,
createdAt: true,
updatedAt: true,
webhookSent: true,
nocodbVerified: true,
nocodbVerifiedAt: true,
processedAt: true,
});
export type Avoir = typeof avoirs.$inferSelect;
export type InsertAvoir = z.infer<typeof insertAvoirSchema>;
B) client/src/pages/Avoirs.tsx
FICHIER COMPLET CORRIGÉ - Copiez tout :
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Plus, Search, FileText, CheckCircle, Clock, AlertCircle } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
// Types
interface Avoir {
id: number;
supplierId: number;
groupId: number;
invoiceReference?: string; // OPTIONNEL
amount?: number; // OPTIONNEL
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;
phone?: string;
};
group: {
id: number;
name: string;
color: string;
};
creator: {
id: string;
username: string;
firstName?: string;
lastName?: 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().optional(),
amount: z.number().optional(),
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: undefined,
comment: "",
commercialProcessed: false,
},
});
// Handle form submission
const onSubmit = (data: AvoirFormData) => {
createAvoirMutation.mutate(data);
};
// Filter avoirs based on search term - CORRECTION CRITIQUE
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>;
}
// Check permissions
if ((user as any)?.role === 'employee') {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Accès refusé</h2>
<p className="text-gray-600">Vous n'avez pas les permissions pour accéder aux avoirs.</p>
</div>
</div>
);
}
const canCreate = ['admin', 'directeur', 'manager'].includes((user as any)?.role);
const userGroups = (user as any)?.userGroups || [];
const availableGroups = (user as any)?.role === 'admin' ? groups : userGroups.map((ug: any) => ug.group);
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Avoirs</h1>
<p className="text-gray-600 mt-2">
Gestion des demandes d'avoirs et suivi des remboursements
</p>
</div>
{canCreate && (
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Créer un avoir
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Créer un nouvel avoir</DialogTitle>
</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>
{availableGroups.map((group: any) => (
<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 (optionnel)</FormLabel>
<FormControl>
<Input {...field} placeholder="FAC-2024-001" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Montant (optionnel)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="0.00"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value ? parseFloat(value) : undefined);
}}
value={field.value || ''}
/>
</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 as any)?.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-2/3"></div>
</div>
</CardContent>
</Card>
))
) : filteredAvoirs.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucun avoir trouvé</h3>
<p className="text-gray-500 text-center mb-6">
{searchTerm ? "Aucun avoir ne correspond à votre recherche." : "Commencez par créer votre premier avoir."}
</p>
{canCreate && !searchTerm && (
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Créer un avoir
</Button>
)}
</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 || 'Sans référence'}</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 ? avoir.amount.toFixed(2) + ' €' : 'Non spécifié'}</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-4 text-xs">
{avoir.webhookSent && (
<div className="flex items-center text-green-600">
<CheckCircle className="h-3 w-3 mr-1" />
Webhook envoyé
</div>
)}
{avoir.nocodbVerified && (
<div className="flex items-center text-blue-600">
<CheckCircle className="h-3 w-3 mr-1" />
NocoDB vérifié
</div>
)}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}
ÉTAPE 3 - Redémarrer votre serveur
# Sur votre serveur
sudo systemctl restart votre-service-node
# ou
pm2 restart all
# ou simplement redémarrer Node.js
ÉTAPE 4 - Test final
- Ouvrez votre application
- Allez dans le menu Avoirs
- Créez un avoir sans référence facture ni montant
- Vérifiez que ça fonctionne
✅ RÉSULTAT ATTENDU
- Plus d'erreur "contact_email"
- Interface qui se charge sans crash
- Possibilité de créer des avoirs avec champs optionnels
- Affichage correct des listes
🆘 SI ÇA NE MARCHE TOUJOURS PAS
Ouvrez la console de votre navigateur (F12) et copiez-moi EXACTEMENT les erreurs affichées.