mirror of
https://github.com/R0m1k3/Regisflow.git
synced 2026-10-11 17:29:48 +02:00
Enhance user experience and interface of sales and dashboard sections
Refactor UI components in NewSaleForm.tsx, SalesHistory.tsx, index.css, validation.ts, and Dashboard.tsx for improved UX. Replit-Commit-Author: Agent Replit-Commit-Session-Id: dec54137-d5a2-4406-8ad4-5f2f9fb110bf Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0715008c-7244-40f4-befc-26c014852236/c74640af-aff6-4467-98ef-6922e186fed2.jpg
This commit is contained in:
1 parent
b2ac262871
commit
71db65e5b5
5 files changed
+709
-1045
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -12,7 +11,7 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useStoreContext } from '@/hooks/useStoreContext';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { validateRequiredFields, validateEAN13, ARTICLE_CATEGORY_MAPPING, IDENTITY_TYPES, PAYMENT_METHODS } from '@/lib/validation';
|
||||
import { Package, User, Users, Save, X } from 'lucide-react';
|
||||
import { Package, User, Users, Save, Calendar, BadgeCheck, Info } from 'lucide-react';
|
||||
|
||||
interface FormData {
|
||||
vendeur: string;
|
||||
@@ -66,7 +65,6 @@ export default function NewSaleForm() {
|
||||
mutationFn: async (saleData: any) => {
|
||||
const dataToSend = {
|
||||
...saleData,
|
||||
// Include storeId for admin users
|
||||
...(user?.role === 'admin' && selectedStoreId && { storeId: selectedStoreId })
|
||||
};
|
||||
|
||||
@@ -77,7 +75,6 @@ export default function NewSaleForm() {
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate all sales queries to refresh the history
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/sales'], exact: false });
|
||||
toast({
|
||||
title: "Vente enregistrée",
|
||||
@@ -115,7 +112,6 @@ export default function NewSaleForm() {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = validateRequiredFields(data);
|
||||
if (missingFields.length > 0) {
|
||||
toast({
|
||||
@@ -126,7 +122,6 @@ export default function NewSaleForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate EAN13
|
||||
if (!validateEAN13(data.gencode)) {
|
||||
toast({
|
||||
title: "Code EAN13 invalide",
|
||||
@@ -162,17 +157,15 @@ export default function NewSaleForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="modern-card">
|
||||
<CardContent className="pt-6">
|
||||
<div className="elegant-card animate-slide-in">
|
||||
<div className="p-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Seller Information */}
|
||||
<div className="modern-section modern-section-vendor">
|
||||
<div className="modern-section-header">
|
||||
<div className="w-10 h-10 bg-purple-500 text-white flex items-center justify-center border-2 border-purple-500 shadow-md">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="truncate">Informations Vendeur</span>
|
||||
{/* Section Vendeur */}
|
||||
<div className="section-vendor">
|
||||
<div className="section-header">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Informations Vendeur</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
@@ -193,7 +186,7 @@ export default function NewSaleForm() {
|
||||
name="dateVente"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Date de vente</FormLabel>
|
||||
<FormLabel>Date de vente <span className="text-destructive">*</span></FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" className="modern-input" {...field} />
|
||||
</FormControl>
|
||||
@@ -204,13 +197,11 @@ export default function NewSaleForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Information */}
|
||||
<div className="modern-section modern-section-product">
|
||||
<div className="modern-section-header">
|
||||
<div className="w-10 h-10 bg-blue-500 text-white flex items-center justify-center border-2 border-blue-500 shadow-md">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="truncate">Informations Produit</span>
|
||||
{/* Section Produit */}
|
||||
<div className="section-product">
|
||||
<div className="section-header">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Informations Produit</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
@@ -221,10 +212,10 @@ export default function NewSaleForm() {
|
||||
<FormLabel>Type d'article <span className="text-destructive">*</span></FormLabel>
|
||||
<Select value={field.value} onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue('categorie', ''); // Reset category when article changes
|
||||
form.setValue('categorie', '');
|
||||
}}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="modern-select-trigger">
|
||||
<SelectTrigger className="modern-input">
|
||||
<SelectValue placeholder="Sélectionner un type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
@@ -248,7 +239,7 @@ export default function NewSaleForm() {
|
||||
<FormLabel>Catégorie réglementaire <span className="text-destructive">*</span></FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={!typeArticle}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="modern-select-trigger">
|
||||
<SelectTrigger className="modern-input">
|
||||
<SelectValue placeholder="Sélectionner une catégorie" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
@@ -292,29 +283,21 @@ export default function NewSaleForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<AlertDescription>
|
||||
<strong>Information réglementaire :</strong>
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
<li>• <strong>F1</strong> : petits artifices (clac-doigts, mini-fontaines), accessibles dès 12 ans.</li>
|
||||
<li>• <strong>F2, F3</strong> : artifices plus puissants, vente et utilisation réservées aux majeurs.</li>
|
||||
</ul>
|
||||
<Alert className="mt-4 border-blue-200 bg-blue-50">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription className="text-blue-800">
|
||||
<strong>Information réglementaire :</strong> F2/F3 = artifices puissants réservés aux majeurs, vente encadrée
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Customer Information */}
|
||||
<div className="modern-section modern-section-customer">
|
||||
<div className="modern-section-header">
|
||||
<div className="w-10 h-10 bg-emerald-500 text-white flex items-center justify-center border-2 border-emerald-500 shadow-md">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="truncate">Informations Client</span>
|
||||
{/* Section Client */}
|
||||
<div className="section-client">
|
||||
<div className="section-header">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>Informations Client</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 mobile-gap-responsive">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nom"
|
||||
@@ -375,7 +358,7 @@ export default function NewSaleForm() {
|
||||
<FormLabel>Mode de paiement <span className="text-destructive">*</span></FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="modern-select-trigger">
|
||||
<SelectTrigger className="modern-input">
|
||||
<SelectValue placeholder="Sélectionner le mode de paiement" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
@@ -394,17 +377,13 @@ export default function NewSaleForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity Information */}
|
||||
<div className="modern-section modern-section-identity">
|
||||
<div className="modern-section-header">
|
||||
<div className="w-10 h-10 bg-amber-500 text-white flex items-center justify-center border-2 border-amber-500 shadow-md">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="truncate">Pièce d'Identité</span>
|
||||
{/* Section Identité */}
|
||||
<div className="section-identity">
|
||||
<div className="section-header">
|
||||
<BadgeCheck className="h-5 w-5" />
|
||||
<span>Pièce d'Identité</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 mobile-gap-responsive">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="typeIdentite"
|
||||
@@ -413,7 +392,7 @@ export default function NewSaleForm() {
|
||||
<FormLabel>Type de pièce d'identité <span className="text-destructive">*</span></FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="modern-select-trigger">
|
||||
<SelectTrigger className="modern-input">
|
||||
<SelectValue placeholder="Sélectionner le type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
@@ -436,7 +415,7 @@ export default function NewSaleForm() {
|
||||
<FormItem>
|
||||
<FormLabel>Numéro de pièce d'identité <span className="text-destructive">*</span></FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Numéro de la pièce d'identité" className="modern-input" {...field} />
|
||||
<Input placeholder="Numéro de la pièce" className="modern-input" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -447,9 +426,9 @@ export default function NewSaleForm() {
|
||||
name="autoriteDelivrance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Autorité de délivrance <span className="text-destructive">*</span></FormLabel>
|
||||
<FormLabel>Autorité de délivrance</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Préfecture, Mairie, etc." className="modern-input" {...field} />
|
||||
<Input placeholder="Autorité ayant délivré la pièce (optionnel)" className="modern-input" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -471,49 +450,27 @@ export default function NewSaleForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-4 pt-6 sm:pt-8 mt-6 sm:mt-8 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-2 hover:bg-muted smooth-transition touch-friendly-button order-2 sm:order-1"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
vendeur: '',
|
||||
dateVente: new Date().toISOString().split('T')[0],
|
||||
typeArticle: '',
|
||||
categorie: '',
|
||||
quantite: '',
|
||||
gencode: '',
|
||||
nom: '',
|
||||
prenom: '',
|
||||
dateNaissance: '',
|
||||
lieuNaissance: '',
|
||||
modePaiement: 'Espèce',
|
||||
typeIdentite: '',
|
||||
numeroIdentite: '',
|
||||
autoriteDelivrance: '',
|
||||
dateDelivrance: '',
|
||||
});
|
||||
}}>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isSubmitting || createSaleMutation.isPending}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground border-0 shadow-lg hover:shadow-xl order-1 sm:order-2"
|
||||
{/* Boutons d'action */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="modern-button modern-button-success flex-1"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
<span className="hidden sm:inline">{isSubmitting || createSaleMutation.isPending ? 'Enregistrement...' : 'Enregistrer la Vente'}</span>
|
||||
<span className="sm:hidden">{isSubmitting || createSaleMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}</span>
|
||||
</Button>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Enregistrement...' : 'Enregistrer la vente'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => form.reset()}
|
||||
className="modern-button modern-button-secondary flex-1"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
@@ -11,7 +10,7 @@ import { useStoreContext } from '@/hooks/useStoreContext';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import type { Sale } from '@shared/schema';
|
||||
import { History, Download, Trash2, Eye, Filter, FileText } from 'lucide-react';
|
||||
import { History, Download, Trash2, Eye, Filter, FileText, Search, X, Calendar, Users } from 'lucide-react';
|
||||
|
||||
interface SalesHistoryProps {
|
||||
canDelete?: boolean;
|
||||
@@ -25,6 +24,7 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Query for sales
|
||||
const { data: salesData, isLoading } = useQuery<Sale[]>({
|
||||
@@ -34,7 +34,6 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
if (startDate) params.append('startDate', startDate);
|
||||
if (endDate) params.append('endDate', endDate);
|
||||
|
||||
// Always add storeId for queries
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
@@ -43,19 +42,35 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
const response = await apiRequest(`/api/sales${queryString ? `?${queryString}` : ''}`);
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedStoreId && !!user, // Only run query when store is selected and user is loaded
|
||||
refetchOnMount: true, // Always refetch when component mounts
|
||||
refetchOnWindowFocus: false, // Don't refetch on window focus
|
||||
enabled: !!selectedStoreId && !!user,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const sales = Array.isArray(salesData) ? salesData : [];
|
||||
|
||||
// Filtrer les ventes selon la recherche
|
||||
const filteredSales = useMemo(() => {
|
||||
if (!searchQuery.trim()) return sales;
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
return sales.filter(sale =>
|
||||
sale.nom.toLowerCase().includes(query) ||
|
||||
sale.prenom.toLowerCase().includes(query) ||
|
||||
sale.vendeur.toLowerCase().includes(query) ||
|
||||
sale.typeArticle.toLowerCase().includes(query) ||
|
||||
sale.gencode.includes(query) ||
|
||||
sale.numeroIdentite.toLowerCase().includes(query) ||
|
||||
(sale.lieuNaissance && sale.lieuNaissance.toLowerCase().includes(query)) ||
|
||||
sale.modePaiement.toLowerCase().includes(query)
|
||||
);
|
||||
}, [sales, searchQuery]);
|
||||
|
||||
// Delete mutation
|
||||
const deleteSaleMutation = useMutation({
|
||||
mutationFn: (saleId: number) =>
|
||||
apiRequest(`/api/sales/${saleId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
// Invalidate all sales queries to refresh the history
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/sales'], exact: false });
|
||||
toast({
|
||||
title: "Vente supprimée",
|
||||
@@ -77,7 +92,7 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
};
|
||||
|
||||
const exportToPDF = async () => {
|
||||
if (sales.length === 0) {
|
||||
if (filteredSales.length === 0) {
|
||||
toast({
|
||||
title: "Aucune donnée",
|
||||
description: "Aucune vente à exporter",
|
||||
@@ -92,84 +107,44 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Configuration du document
|
||||
doc.setFont('helvetica');
|
||||
|
||||
// Titre du document
|
||||
doc.setFontSize(16);
|
||||
doc.text('Historique des Ventes de Feux d\'Artifice', 20, 20);
|
||||
|
||||
// Informations générales
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Date d'export: ${new Date().toLocaleDateString('fr-FR')}`, 20, 30);
|
||||
doc.text(`Nombre de ventes: ${sales.length}`, 20, 35);
|
||||
doc.text(`Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}`, 20, 30);
|
||||
|
||||
if (startDate || endDate) {
|
||||
let periodText = 'Période: ';
|
||||
if (startDate) periodText += `du ${new Date(startDate).toLocaleDateString('fr-FR')} `;
|
||||
if (endDate) periodText += `au ${new Date(endDate).toLocaleDateString('fr-FR')}`;
|
||||
doc.text(periodText, 20, 40);
|
||||
const period = `Période: ${startDate ? new Date(startDate).toLocaleDateString('fr-FR') : 'début'} - ${endDate ? new Date(endDate).toLocaleDateString('fr-FR') : 'fin'}`;
|
||||
doc.text(period, 20, 35);
|
||||
}
|
||||
|
||||
// Données du tableau
|
||||
const tableData = sales.map(sale => [
|
||||
new Date(sale.timestamp!).toLocaleDateString('fr-FR'),
|
||||
const tableData = filteredSales.map(sale => [
|
||||
new Date(sale.timestamp).toLocaleDateString('fr-FR'),
|
||||
sale.vendeur,
|
||||
`${sale.nom} ${sale.prenom}`,
|
||||
sale.typeArticle,
|
||||
sale.categorie,
|
||||
sale.quantite.toString(),
|
||||
sale.nom + ' ' + sale.prenom,
|
||||
sale.modePaiement || 'Espèce',
|
||||
sale.typeIdentite,
|
||||
sale.numeroIdentite
|
||||
sale.modePaiement,
|
||||
sale.gencode
|
||||
]);
|
||||
|
||||
// Configuration du tableau
|
||||
autoTable(doc, {
|
||||
head: [['Date', 'Vendeur', 'Article', 'Cat.', 'Qté', 'Client', 'Paiement', 'ID Type', 'N° ID']],
|
||||
head: [['Date', 'Vendeur', 'Client', 'Article', 'Cat.', 'Qty', 'Paiement', 'Code']],
|
||||
body: tableData,
|
||||
startY: startDate || endDate ? 50 : 45,
|
||||
styles: {
|
||||
fontSize: 8,
|
||||
cellPadding: 2,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [59, 130, 246],
|
||||
textColor: 255,
|
||||
fontSize: 9,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [248, 250, 252],
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 18 }, // Date
|
||||
1: { cellWidth: 25 }, // Vendeur
|
||||
2: { cellWidth: 35 }, // Article
|
||||
3: { cellWidth: 12 }, // Catégorie
|
||||
4: { cellWidth: 12 }, // Quantité
|
||||
5: { cellWidth: 30 }, // Client
|
||||
6: { cellWidth: 20 }, // Paiement
|
||||
7: { cellWidth: 15 }, // ID Type
|
||||
8: { cellWidth: 25 }, // N° ID
|
||||
},
|
||||
margin: { left: 10, right: 10 },
|
||||
startY: startDate || endDate ? 45 : 40,
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [64, 64, 64] },
|
||||
margin: { top: 40, left: 20, right: 20 },
|
||||
});
|
||||
|
||||
// Pied de page
|
||||
const pageCount = doc.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.text(`Page ${i} sur ${pageCount}`, doc.internal.pageSize.width - 30, doc.internal.pageSize.height - 10);
|
||||
doc.text('RegisFlow - Registre des ventes de feux d\'artifice', 20, doc.internal.pageSize.height - 10);
|
||||
}
|
||||
|
||||
// Téléchargement
|
||||
doc.save(`historique_ventes_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
|
||||
doc.save(`ventes-feux-artifice-${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
|
||||
toast({
|
||||
title: "Export PDF réussi",
|
||||
description: `${sales.length} ventes exportées en PDF`,
|
||||
title: "Export réussi",
|
||||
description: "Le fichier PDF a été téléchargé",
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -182,254 +157,266 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (sales.length === 0) {
|
||||
toast({
|
||||
title: "Aucune donnée",
|
||||
description: "Aucune vente à exporter",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const csvHeader = [
|
||||
'Date/Heure',
|
||||
'Vendeur',
|
||||
'Date de vente',
|
||||
'Type d\'article',
|
||||
'Catégorie',
|
||||
'Quantité',
|
||||
'Gencode',
|
||||
'Nom client',
|
||||
'Prénom client',
|
||||
'Date de naissance',
|
||||
'Lieu de naissance',
|
||||
'Mode de paiement',
|
||||
'Type identité',
|
||||
'Numéro identité',
|
||||
'Autorité délivrance',
|
||||
'Date délivrance'
|
||||
].join(',');
|
||||
|
||||
const csvData = sales.map(sale => [
|
||||
new Date(sale.timestamp!).toLocaleString('fr-FR'),
|
||||
sale.vendeur,
|
||||
sale.dateVente,
|
||||
sale.typeArticle,
|
||||
sale.categorie,
|
||||
sale.quantite,
|
||||
sale.gencode,
|
||||
sale.nom,
|
||||
sale.prenom,
|
||||
sale.dateNaissance,
|
||||
sale.lieuNaissance || '',
|
||||
sale.modePaiement || 'Espèce',
|
||||
sale.typeIdentite,
|
||||
sale.numeroIdentite,
|
||||
sale.autoriteDelivrance,
|
||||
sale.dateDelivrance
|
||||
].map(field => `"${field}"`).join(',')).join('\n');
|
||||
|
||||
const csvContent = csvHeader + '\n' + csvData;
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `ventes_fireworks_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
|
||||
toast({
|
||||
title: "Export réussi",
|
||||
description: `${sales.length} ventes exportées`,
|
||||
variant: "success",
|
||||
});
|
||||
const clearFilters = () => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('fr-FR');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="modern-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="modern-icon-container">
|
||||
<History className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="truncate">Historique des Ventes</span>
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Consultation et gestion des ventes enregistrées
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-end modern-filter-card">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 flex-1 w-full">
|
||||
<div>
|
||||
<label className="font-medium text-sm block mb-2">Date de début</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="modern-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-medium text-sm block mb-2">Date de fin</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="modern-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button onClick={exportToCSV} variant="outline" className="modern-button flex-1 sm:flex-none">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">Export CSV</span>
|
||||
<span className="sm:hidden">CSV</span>
|
||||
</Button>
|
||||
<Button onClick={exportToPDF} variant="outline" className="modern-button flex-1 sm:flex-none">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">Export PDF</span>
|
||||
<span className="sm:hidden">PDF</span>
|
||||
</Button>
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="elegant-card">
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Sales Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-in">
|
||||
{/* Module de recherche */}
|
||||
<div className="search-container">
|
||||
<div className="search-header">
|
||||
<Search className="h-5 w-5" />
|
||||
<span>Rechercher dans l'historique</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Rechercher par nom, prénom, vendeur, article, code..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : sales.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground responsive-body">
|
||||
Aucune vente trouvée
|
||||
{searchQuery && (
|
||||
<div className="text-sm text-gray-600">
|
||||
{filteredSales.length} résultat{filteredSales.length > 1 ? 's' : ''} trouvé{filteredSales.length > 1 ? 's' : ''} sur {sales.length} vente{sales.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="filters-container">
|
||||
<div className="section-header mb-4">
|
||||
<Filter className="h-5 w-5" />
|
||||
<span>Filtres par date</span>
|
||||
</div>
|
||||
<div className="filters-grid">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Date de début</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="modern-input"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="responsive-caption">Date/Heure</TableHead>
|
||||
<TableHead className="responsive-caption">Vendeur</TableHead>
|
||||
<TableHead className="responsive-caption">Client</TableHead>
|
||||
<TableHead className="responsive-caption">Article</TableHead>
|
||||
<TableHead className="responsive-caption">Quantité</TableHead>
|
||||
<TableHead className="responsive-caption hidden lg:table-cell">Paiement</TableHead>
|
||||
<TableHead className="responsive-caption hidden sm:table-cell">Gencode</TableHead>
|
||||
<TableHead className="responsive-caption">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sales.map((sale) => (
|
||||
<TableRow key={sale.id}>
|
||||
<TableCell>{formatDateTime(sale.timestamp!)}</TableCell>
|
||||
<TableCell>{sale.vendeur}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{sale.nom} {sale.prenom}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(sale.dateNaissance)}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Date de fin</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="modern-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="modern-button modern-button-secondary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions d'export */}
|
||||
<div className="elegant-card">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<History className="h-5 w-5" />
|
||||
<span className="font-medium">
|
||||
{filteredSales.length} vente{filteredSales.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<div className="text-sm text-blue-600 bg-blue-50 px-2 py-1 rounded">
|
||||
Recherche active
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={exportToPDF}
|
||||
disabled={filteredSales.length === 0}
|
||||
className="modern-button modern-button-primary"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Exporter en PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table des ventes */}
|
||||
<div className="elegant-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Vendeur</th>
|
||||
<th>Client</th>
|
||||
<th>Article</th>
|
||||
<th>Cat.</th>
|
||||
<th>Qté</th>
|
||||
<th>Paiement</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSales.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? 'Aucun résultat pour cette recherche' : 'Aucune vente enregistrée'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredSales.map((sale) => (
|
||||
<tr key={sale.id}>
|
||||
<td className="font-mono text-sm">
|
||||
{new Date(sale.timestamp).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td className="font-medium">{sale.vendeur}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-gray-400" />
|
||||
<span>{sale.nom} {sale.prenom}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{sale.typeArticle}</p>
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
|
||||
sale.categorie === 'F3'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{sale.categorie}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{sale.quantite}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="inline-flex px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
|
||||
{sale.modePaiement || 'Espèce'}
|
||||
</td>
|
||||
<td className="text-sm">{sale.typeArticle}</td>
|
||||
<td>
|
||||
<span className={`modern-badge ${
|
||||
sale.categorie === 'F3' ? 'badge-destructive' : 'badge-secondary'
|
||||
}`}>
|
||||
{sale.categorie}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">{sale.gencode}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
</td>
|
||||
<td className="text-center">{sale.quantite}</td>
|
||||
<td className="text-sm">{sale.modePaiement}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedSale(sale)}
|
||||
>
|
||||
<button className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Détails de la vente</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedSale && (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Informations générales</h4>
|
||||
<p><strong>Date/Heure :</strong> {formatDateTime(selectedSale.timestamp!)}</p>
|
||||
<p><strong>Vendeur :</strong> {selectedSale.vendeur}</p>
|
||||
<p><strong>Date de vente :</strong> {formatDate(selectedSale.dateVente)}</p>
|
||||
<label className="text-sm font-medium text-gray-600">Date de vente</label>
|
||||
<p className="font-mono">{new Date(sale.timestamp).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Produit</h4>
|
||||
<p><strong>Type :</strong> {selectedSale.typeArticle}</p>
|
||||
<p><strong>Catégorie :</strong> {selectedSale.categorie}</p>
|
||||
<p><strong>Quantité :</strong> {selectedSale.quantite}</p>
|
||||
<p><strong>Gencode :</strong> {selectedSale.gencode}</p>
|
||||
<label className="text-sm font-medium text-gray-600">Vendeur</label>
|
||||
<p>{sale.vendeur}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Client</h4>
|
||||
<p><strong>Nom :</strong> {selectedSale.nom}</p>
|
||||
<p><strong>Prénom :</strong> {selectedSale.prenom}</p>
|
||||
<p><strong>Date de naissance :</strong> {formatDate(selectedSale.dateNaissance)}</p>
|
||||
{selectedSale.lieuNaissance && (
|
||||
<p><strong>Lieu de naissance :</strong> {selectedSale.lieuNaissance}</p>
|
||||
)}
|
||||
<p><strong>Mode de paiement :</strong> {selectedSale.modePaiement || 'Espèce'}</p>
|
||||
<label className="text-sm font-medium text-gray-600">Client</label>
|
||||
<p>{sale.nom} {sale.prenom}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Pièce d'identité</h4>
|
||||
<p><strong>Type :</strong> {selectedSale.typeIdentite}</p>
|
||||
<p><strong>Numéro :</strong> {selectedSale.numeroIdentite}</p>
|
||||
<p><strong>Autorité :</strong> {selectedSale.autoriteDelivrance}</p>
|
||||
<p><strong>Date délivrance :</strong> {formatDate(selectedSale.dateDelivrance)}</p>
|
||||
<label className="text-sm font-medium text-gray-600">Date de naissance</label>
|
||||
<p>{new Date(sale.dateNaissance).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Lieu de naissance</label>
|
||||
<p>{sale.lieuNaissance || 'Non renseigné'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Type d'article</label>
|
||||
<p>{sale.typeArticle}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Catégorie</label>
|
||||
<p>{sale.categorie}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Quantité</label>
|
||||
<p>{sale.quantite}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Gencode</label>
|
||||
<p className="font-mono">{sale.gencode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Mode de paiement</label>
|
||||
<p>{sale.modePaiement}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Type d'identité</label>
|
||||
<p>{sale.typeIdentite}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Numéro d'identité</label>
|
||||
<p className="font-mono">{sale.numeroIdentite}</p>
|
||||
</div>
|
||||
{sale.autoriteDelivrance && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Autorité de délivrance</label>
|
||||
<p>{sale.autoriteDelivrance}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Date de délivrance</label>
|
||||
<p>{new Date(sale.dateDelivrance).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{canDelete && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deleteSaleMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
<button className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||
<AlertDialogTitle>Supprimer la vente</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer cette vente ? Cette action est irréversible.
|
||||
<br /><br />
|
||||
<strong>Client :</strong> {sale.nom} {sale.prenom}<br />
|
||||
<strong>Article :</strong> {sale.typeArticle}<br />
|
||||
<strong>Date :</strong> {formatDateTime(sale.timestamp!)}
|
||||
Êtes-vous sûr de vouloir supprimer cette vente ?
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg">
|
||||
<p><strong>Client :</strong> {sale.nom} {sale.prenom}</p>
|
||||
<p><strong>Article :</strong> {sale.typeArticle}</p>
|
||||
<p><strong>Date :</strong> {new Date(sale.timestamp).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
Cette action est irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -445,14 +432,14 @@ export default function SalesHistory({ canDelete = false }: SalesHistoryProps) {
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+376
-656
File diff suppressed because it is too large.
Load diff
@@ -27,8 +27,8 @@ export function validateRequiredFields(formData: any): string[] {
|
||||
{ key: 'modePaiement', label: 'Mode de paiement' },
|
||||
{ key: 'typeIdentite', label: 'Type de pièce d\'identité' },
|
||||
{ key: 'numeroIdentite', label: 'Numéro de pièce d\'identité' },
|
||||
{ key: 'autoriteDelivrance', label: 'Autorité de délivrance' },
|
||||
{ key: 'dateDelivrance', label: 'Date de délivrance' }
|
||||
// Note: autoriteDelivrance n'est plus obligatoire
|
||||
];
|
||||
|
||||
return requiredFields
|
||||
|
||||
@@ -41,7 +41,7 @@ function DashboardContent() {
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Modern Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="max-w-7xl mx-auto mobile-padding">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
|
||||
Reference in new issue
Block a user