From e91b8f1a8b79192506f9881e43e3ad51366e11db Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Sat, 19 Jul 2025 10:10:41 +0000 Subject: [PATCH] Enable support for multiple products within a single sale Implements multi-product sales functionality by adding a `sale_products` table, updating the sales form, and modifying server-side logic. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e80fed01-7137-4997-b9af-dad57132d430 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0715008c-7244-40f4-befc-26c014852236/e80fed01-7137-4997-b9af-dad57132d430/58t7aEc --- .../auto-backup-2025-07-13T22-00-00-641Z.json | 398 ++++++++++ client/src/components/NewSaleFormMulti.tsx | 700 ++++++++++++++++++ client/src/lib/validation.ts | 42 +- client/src/pages/Dashboard.tsx | 4 +- client/src/types/sale.ts | 27 +- init.sql | 27 +- migration-multi-products.sql | 42 ++ server/routes.ts | 20 +- server/storage.ts | 55 +- shared/schema.ts | 38 +- 10 files changed, 1303 insertions(+), 50 deletions(-) create mode 100644 backups/auto-backup-2025-07-13T22-00-00-641Z.json create mode 100644 client/src/components/NewSaleFormMulti.tsx create mode 100644 migration-multi-products.sql diff --git a/backups/auto-backup-2025-07-13T22-00-00-641Z.json b/backups/auto-backup-2025-07-13T22-00-00-641Z.json new file mode 100644 index 0000000..6660bb7 --- /dev/null +++ b/backups/auto-backup-2025-07-13T22-00-00-641Z.json @@ -0,0 +1,398 @@ +{ + "timestamp": "2025-07-13T22:00:00.641Z", + "version": "1.0", + "type": "automatic", + "data": { + "users": [ + { + "id": 5, + "username": "ff579", + "email": null, + "firstName": "", + "lastName": "", + "role": "employee", + "storeId": 2, + "isActive": true, + "createdAt": "2025-07-10T09:48:22.256Z", + "updatedAt": "2025-07-10T09:48:22.256Z" + }, + { + "id": 4, + "username": "ff292", + "email": null, + "firstName": "", + "lastName": "", + "role": "employee", + "storeId": 1, + "isActive": true, + "createdAt": "2025-07-10T09:48:06.549Z", + "updatedAt": "2025-07-10T09:48:06.549Z" + }, + { + "id": 3, + "username": "gael", + "email": null, + "firstName": "", + "lastName": "", + "role": "administrator", + "storeId": null, + "isActive": true, + "createdAt": "2025-07-10T08:47:04.063Z", + "updatedAt": "2025-07-10T08:47:04.063Z" + }, + { + "id": 2, + "username": "FFNANCY", + "email": "michaelschal@gmail.com", + "firstName": "", + "lastName": "", + "role": "manager", + "storeId": 1, + "isActive": true, + "createdAt": "2025-07-09T18:55:01.413Z", + "updatedAt": "2025-07-09T18:55:01.413Z" + }, + { + "id": 1, + "username": "admin", + "email": "michaelschal@ffest.fr", + "firstName": "Administrateur", + "lastName": "Principal", + "role": "admin", + "storeId": 1, + "isActive": true, + "createdAt": "2025-07-09T18:37:53.865Z", + "updatedAt": "2025-07-09T18:37:53.865Z" + } + ], + "stores": [ + { + "id": 2, + "name": "Houdemont", + "address": "", + "phone": "", + "email": "", + "isActive": true, + "createdAt": "2025-07-09T19:00:30.782Z", + "updatedAt": "2025-07-09T19:00:30.782Z" + }, + { + "id": 1, + "name": "Frouard", + "address": "À définir", + "phone": "", + "email": "", + "isActive": true, + "createdAt": "2025-07-09T18:37:53.432Z", + "updatedAt": "2025-07-09T18:37:53.432Z" + } + ], + "sales": [ + { + "id": 8, + "storeId": 2, + "userId": 1, + "timestamp": "2025-07-11T18:27:55.231Z", + "vendeur": "Test Vendeur", + "typeArticle": "Batterie Test", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890123", + "nom": "DUPONT", + "prenom": "Jean", + "dateNaissance": "01/01/1980", + "lieuNaissance": "Paris", + "modePaiement": "Carte bancaire", + "typeIdentite": "Carte d'identité", + "numeroIdentite": "ABC123456", + "autoriteDelivrance": "Préfecture de Paris", + "dateDelivrance": "01/01/2020", + "photo_recto": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "photo_verso": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "photo_ticket": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + }, + { + "id": 7, + "storeId": 2, + "userId": 1, + "timestamp": "2025-07-11T15:36:32.194Z", + "vendeur": "Moi", + "typeArticle": "Batterie", + "categorie": "F3", + "quantite": 1, + "gencode": "3660092323745", + "nom": "SCHAL", + "prenom": "Michaël", + "dateNaissance": "1998-02-05", + "lieuNaissance": "Bar le duc", + "modePaiement": "Espèce", + "typeIdentite": "CNI", + "numeroIdentite": "qsdfqsdgc23445", + "autoriteDelivrance": "Frouard", + "dateDelivrance": "2025-07-11", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 5, + "storeId": 2, + "userId": 2, + "timestamp": "2025-07-09T19:16:46.797Z", + "vendeur": "Moi", + "typeArticle": "Fusée", + "categorie": "F2", + "quantite": 10, + "gencode": "3660092323745", + "nom": " ASSOCIATION GRAND AIR", + "prenom": "Anne-Laure", + "dateNaissance": "2025-06-03", + "lieuNaissance": "Bar le duc", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "qsdfqsdgc23445", + "autoriteDelivrance": "Frouard", + "dateDelivrance": "2025-07-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 2, + "storeId": 2, + "userId": 1, + "timestamp": "2025-07-09T19:07:15.650Z", + "vendeur": "Moi", + "typeArticle": "Pétard aérien", + "categorie": "F3", + "quantite": 1, + "gencode": "3660092323745", + "nom": "SCHAL", + "prenom": "Michaël", + "dateNaissance": "2025-07-02", + "lieuNaissance": "Bar le duc", + "modePaiement": "Espèce", + "typeIdentite": "CNI", + "numeroIdentite": "qsdfqsdgc23445", + "autoriteDelivrance": "Frouard", + "dateDelivrance": "2025-07-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 1, + "storeId": 2, + "userId": 1, + "timestamp": "2025-07-09T19:05:20.904Z", + "vendeur": "Moi", + "typeArticle": "Pétard aérien", + "categorie": "F3", + "quantite": 1, + "gencode": "3660092323745", + "nom": "SCHAL", + "prenom": "Michaël", + "dateNaissance": "2025-07-02", + "lieuNaissance": "Bar le duc", + "modePaiement": "Espèce", + "typeIdentite": "CNI", + "numeroIdentite": "qsdfqsdgc23445", + "autoriteDelivrance": "Frouard", + "dateDelivrance": "2025-07-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 14, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-13T11:24:50.830Z", + "vendeur": "TEST PRODUCTION", + "typeArticle": "Test", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890123", + "nom": "PRODUCTION", + "prenom": "Ready", + "dateNaissance": "1990-01-01", + "lieuNaissance": "Test", + "modePaiement": "Espèce", + "typeIdentite": "CNI", + "numeroIdentite": "123456789", + "autoriteDelivrance": "Test", + "dateDelivrance": "2025-01-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": "data:image/jpeg;base64,PRODUCTION_READY_TEST" + }, + { + "id": 13, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-11T19:02:01.423Z", + "vendeur": "Production Test", + "typeArticle": "Chandelle", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890999", + "nom": "PROD", + "prenom": "Test", + "dateNaissance": "1990-01-01", + "lieuNaissance": "Test", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "123456789", + "autoriteDelivrance": "Test", + "dateDelivrance": "2025-01-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": "data:image/jpeg;base64,PRODUCTION_TEST_TICKET" + }, + { + "id": 12, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-11T18:48:31.159Z", + "vendeur": "Test Final", + "typeArticle": "Chandelle", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890999", + "nom": "FINAL", + "prenom": "Test", + "dateNaissance": "1990-01-01", + "lieuNaissance": "Test", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "123456789", + "autoriteDelivrance": "Test", + "dateDelivrance": "2025-01-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": "data:image/jpeg;base64,TICKET_TEST_FINAL" + }, + { + "id": 11, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-11T18:39:03.438Z", + "vendeur": "Test Debug", + "typeArticle": "Chandelle", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890125", + "nom": "DEBUG", + "prenom": "Test", + "dateNaissance": "1990-01-01", + "lieuNaissance": "Test", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "123456789", + "autoriteDelivrance": "Test", + "dateDelivrance": "2025-01-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": "data:image/jpeg;base64,TEST_TICKET_DATA" + }, + { + "id": 10, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-11T18:38:45.251Z", + "vendeur": "Test Vendeur 2", + "typeArticle": "Chandelle", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890124", + "nom": "TEST2", + "prenom": "Photo2", + "dateNaissance": "1990-01-01", + "lieuNaissance": "Test", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "123456789", + "autoriteDelivrance": "Test", + "dateDelivrance": "2025-01-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 9, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-11T18:38:21.913Z", + "vendeur": "Test Vendeur", + "typeArticle": "Chandelle", + "categorie": "F2", + "quantite": 1, + "gencode": "1234567890123", + "nom": "TEST", + "prenom": "Photo", + "dateNaissance": "1990-01-01", + "lieuNaissance": "Test", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "123456789", + "autoriteDelivrance": "Test", + "dateDelivrance": "2025-01-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 6, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-10T00:08:19.097Z", + "vendeur": "Moi", + "typeArticle": "Chandelle romaine", + "categorie": "F2", + "quantite": 1, + "gencode": "3660092323745", + "nom": "SCHAL", + "prenom": "Anne-Laure", + "dateNaissance": "2018-02-10", + "lieuNaissance": "Bar le duc", + "modePaiement": "Espèce", + "typeIdentite": "Passeport", + "numeroIdentite": "qsdfqsdgc23445", + "autoriteDelivrance": "Frouard", + "dateDelivrance": "2025-07-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + }, + { + "id": 4, + "storeId": 1, + "userId": 1, + "timestamp": "2025-07-09T19:11:36.505Z", + "vendeur": "Moi", + "typeArticle": "Batterie", + "categorie": "F3", + "quantite": 1, + "gencode": "3660092323745", + "nom": "SCHAL", + "prenom": "Michaël", + "dateNaissance": "2025-07-18", + "lieuNaissance": "Bar le duc", + "modePaiement": "Espèce", + "typeIdentite": "CNI", + "numeroIdentite": "qsdfqsdgc23445", + "autoriteDelivrance": "Frouard", + "dateDelivrance": "2025-07-01", + "photo_recto": null, + "photo_verso": null, + "photo_ticket": null + } + ] + }, + "metadata": { + "totalUsers": 5, + "totalStores": 2, + "totalSales": 13, + "exportedBy": "system-auto-backup" + } +} \ No newline at end of file diff --git a/client/src/components/NewSaleFormMulti.tsx b/client/src/components/NewSaleFormMulti.tsx new file mode 100644 index 0000000..61fa43b --- /dev/null +++ b/client/src/components/NewSaleFormMulti.tsx @@ -0,0 +1,700 @@ +import { useState } from 'react'; +import { useForm, useFieldArray } from 'react-hook-form'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useToast } from '@/hooks/use-toast'; +import { useAuth } from '@/hooks/useAuth'; +import { useStoreContext } from '@/hooks/useStoreContext'; +import { useSimpleCamera } from '@/hooks/useSimpleCamera'; + +import { apiRequest } from '@/lib/queryClient'; +import { validateRequiredFields, validateEAN13, ARTICLE_CATEGORY_MAPPING, IDENTITY_TYPES, PAYMENT_METHODS } from '@/lib/validation'; +import { Package, User, Users, Save, Calendar, BadgeCheck, Info, Camera, Upload, Trash2, Plus, Minus } from 'lucide-react'; +import SimpleCameraModal from '@/components/SimpleCameraModal'; +import type { PhotoType, FormData } from '@/types/sale'; + +export default function NewSaleFormMulti() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + const { selectedStoreId } = useStoreContext(); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Camera functionality + const { + isOpen: isCameraOpen, + currentPhotoType, + isCapturing, + videoRef, + canvasRef, + startCamera, + capturePhoto, + stopCamera + } = useSimpleCamera(); + + const form = useForm({ + defaultValues: { + vendeur: '', + products: [ + { + typeArticle: '', + categorie: '', + quantite: '', + gencode: '', + } + ], + nom: '', + prenom: '', + dateNaissance: '', + lieuNaissance: '', + modePaiement: 'Espèce', + typeIdentite: '', + numeroIdentite: '', + autoriteDelivrance: '', + dateDelivrance: '', + photoRecto: '', + photoVerso: '', + photoTicket: '', + } + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "products" + }); + + const createSaleMutation = useMutation({ + mutationFn: async (saleData: any) => { + const dataToSend = { + ...saleData, + ...(user?.role === 'admin' && selectedStoreId && { storeId: selectedStoreId }) + }; + + const response = await apiRequest('/api/sales', { + method: 'POST', + body: JSON.stringify(dataToSend), + }); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/sales'], exact: false }); + toast({ + title: "Vente enregistrée", + description: "La vente a été enregistrée avec succès", + variant: "success", + }); + form.reset(); + }, + onError: (error: any) => { + const errorMessage = error.message || 'Erreur lors de la création de la vente'; + toast({ + title: "Erreur", + description: errorMessage, + variant: "destructive", + }); + }, + }); + + const onSubmit = async (data: FormData) => { + console.log('Form data:', data); + setIsSubmitting(true); + + try { + // Validation des champs requis + const missingFields = validateRequiredFields(data); + if (missingFields.length > 0) { + toast({ + title: "Champs obligatoires manquants", + description: `Veuillez remplir : ${missingFields.join(', ')}`, + variant: "destructive", + }); + setIsSubmitting(false); + return; + } + + // Validation EAN-13 pour tous les produits + const invalidBarcodes = data.products.filter(product => !validateEAN13(product.gencode)); + if (invalidBarcodes.length > 0) { + toast({ + title: "Code-barres invalide", + description: "Certains codes EAN-13 ne sont pas valides", + variant: "destructive", + }); + setIsSubmitting(false); + return; + } + + await createSaleMutation.mutateAsync(data); + } catch (error) { + console.error('Submit error:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handlePhotoCapture = async (photoType: PhotoType) => { + await startCamera(photoType); + }; + + const handleRemovePhoto = (photoType: PhotoType) => { + const field = photoType === 'recto' ? 'photoRecto' : + photoType === 'verso' ? 'photoVerso' : 'photoTicket'; + form.setValue(field, ''); + }; + + const handleFileUpload = (photoType: PhotoType, event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const result = e.target?.result as string; + const field = photoType === 'recto' ? 'photoRecto' : + photoType === 'verso' ? 'photoVerso' : 'photoTicket'; + form.setValue(field, result); + }; + reader.readAsDataURL(file); + } + }; + + const addProduct = () => { + append({ + typeArticle: '', + categorie: '', + quantite: '', + gencode: '', + }); + }; + + const removeProduct = (index: number) => { + if (fields.length > 1) { + remove(index); + } + }; + + const getAvailableCategories = (typeArticle: string) => { + return typeArticle ? ARTICLE_CATEGORY_MAPPING[typeArticle as keyof typeof ARTICLE_CATEGORY_MAPPING] || [] : []; + }; + + return ( +
+ + +
+ + + {/* Section Vendeur */} +
+
+ +

Informations vendeur

+
+ + ( + + Nom du vendeur * + + + + + + )} + /> +
+ + {/* Section Produits */} +
+
+
+ +

Produits

+
+ +
+ +
+ {fields.map((field, index) => ( +
+
+

Produit {index + 1}

+ {fields.length > 1 && ( + + )} +
+ +
+ ( + + Type d'article * + + + + )} + /> + + ( + + Catégorie * + + + + )} + /> + + ( + + Quantité * + + + + + + )} + /> + + ( + + Code EAN-13 * + + + + + + )} + /> +
+
+ ))} +
+
+ + {/* Section Client */} +
+
+ +

Informations client

+
+ +
+ ( + + Nom * + + + + + + )} + /> + + ( + + Prénom * + + + + + + )} + /> + + ( + + Date de naissance * + + + + + + )} + /> + + ( + + Lieu de naissance * + + + + + + )} + /> + + ( + + Mode de paiement * + + + + )} + /> +
+
+ + {/* Section Identité */} +
+
+ +

Pièce d'identité

+
+ +
+ ( + + Type de pièce d'identité * + + + + )} + /> + + ( + + Numéro de pièce d'identité * + + + + + + )} + /> + + ( + + Autorité de délivrance + + + + + + )} + /> + + ( + + Date de délivrance * + + + + + + )} + /> +
+ + {/* Photos */} +
+

Photos de la pièce d'identité

+ +
+ {/* Photo Recto */} +
+ +
+ + + {form.watch('photoRecto') && ( + + )} +
+ {form.watch('photoRecto') && ( +
✓ Photo recto capturée
+ )} +
+ + {/* Photo Verso */} +
+ +
+ + + {form.watch('photoVerso') && ( + + )} +
+ {form.watch('photoVerso') && ( +
✓ Photo verso capturée
+ )} +
+
+ + {/* Photo Ticket */} +
+ +
+ + + {form.watch('photoTicket') && ( + + )} +
+ {form.watch('photoTicket') && ( +
✓ Photo ticket capturée
+ )} +
+
+
+ + {/* Boutons d'action */} +
+ + +
+
+ +
+ ); +} \ No newline at end of file diff --git a/client/src/lib/validation.ts b/client/src/lib/validation.ts index 48b361c..0c9156b 100644 --- a/client/src/lib/validation.ts +++ b/client/src/lib/validation.ts @@ -16,10 +16,6 @@ export function validateEAN13(code: string): boolean { export function validateRequiredFields(formData: any): string[] { const requiredFields = [ { key: 'vendeur', label: 'Vendeur' }, - { key: 'typeArticle', label: 'Type d\'article' }, - { key: 'categorie', label: 'Catégorie' }, - { key: 'quantite', label: 'Quantité' }, - { key: 'gencode', label: 'Gencode' }, { key: 'nom', label: 'Nom' }, { key: 'prenom', label: 'Prénom' }, { key: 'dateNaissance', label: 'Date de naissance' }, @@ -31,17 +27,43 @@ export function validateRequiredFields(formData: any): string[] { // Note: autoriteDelivrance n'est plus obligatoire ]; - return requiredFields + const missingFields = requiredFields .filter(field => { const value = formData[field.key]; - // Pour la quantité, accepter 0 comme valeur valide - if (field.key === 'quantite') { - return value === '' || value === null || value === undefined; - } - // Pour les autres champs, vérifier si vide ou juste des espaces return !value || value.toString().trim() === ''; }) .map(field => field.label); + + // Validation des produits + if (!formData.products || !Array.isArray(formData.products) || formData.products.length === 0) { + missingFields.push('Au moins un produit'); + } else { + formData.products.forEach((product: any, index: number) => { + const productFields = [ + { key: 'typeArticle', label: `Type d'article (produit ${index + 1})` }, + { key: 'categorie', label: `Catégorie (produit ${index + 1})` }, + { key: 'quantite', label: `Quantité (produit ${index + 1})` }, + { key: 'gencode', label: `Code EAN-13 (produit ${index + 1})` } + ]; + + productFields.forEach(field => { + const value = product[field.key]; + if (field.key === 'quantite') { + // Pour la quantité, vérifier si c'est un nombre valide > 0 + if (!value || isNaN(Number(value)) || Number(value) <= 0) { + missingFields.push(field.label); + } + } else { + // Pour les autres champs produit + if (!value || value.toString().trim() === '') { + missingFields.push(field.label); + } + } + }); + }); + } + + return missingFields; } export const ARTICLE_CATEGORY_MAPPING = { diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 8540cdf..eeae3c3 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -5,7 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAuth } from "@/hooks/useAuth"; import { StoreProvider, useStoreContext } from "@/hooks/useStoreContext"; -import NewSaleForm from "@/components/NewSaleForm"; +import NewSaleFormMulti from "@/components/NewSaleFormMulti"; import SalesHistory from "@/components/SalesHistory"; import Administration from "@/components/Administration"; import { Package, History, Settings, LogOut, User, Building2, Shield } from "lucide-react"; @@ -146,7 +146,7 @@ function DashboardContent() {
- +
diff --git a/client/src/types/sale.ts b/client/src/types/sale.ts index 8bfb6ba..d7a9801 100644 --- a/client/src/types/sale.ts +++ b/client/src/types/sale.ts @@ -1,3 +1,11 @@ +export interface SaleProduct { + id?: number; + typeArticle: string; + categorie: 'F2' | 'F3'; + quantite: number; + gencode: string; +} + export interface Sale { id: number; timestamp: string; @@ -5,12 +13,6 @@ export interface Sale { // Vendeur vendeur: string; - // Produit - typeArticle: string; - categorie: 'F2' | 'F3'; - quantite: number; - gencode: string; - // Client nom: string; prenom: string; @@ -28,14 +30,19 @@ export interface Sale { photo_recto?: string; photo_verso?: string; photo_ticket?: string; + + // Produits (nouveaux) + products?: SaleProduct[]; } export interface FormData { vendeur: string; - typeArticle: string; - categorie: string; - quantite: string; - gencode: string; + products: { + typeArticle: string; + categorie: string; + quantite: string; + gencode: string; + }[]; nom: string; prenom: string; dateNaissance: string; diff --git a/init.sql b/init.sql index 743fb0a..47f8986 100644 --- a/init.sql +++ b/init.sql @@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS users ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); --- Table des ventes +-- Table des ventes (informations générales) CREATE TABLE IF NOT EXISTS sales ( id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) NOT NULL, @@ -62,12 +62,6 @@ CREATE TABLE IF NOT EXISTS sales ( -- Informations vendeur vendeur VARCHAR(255) NOT NULL, - -- Informations produit - type_article VARCHAR(255) NOT NULL, - categorie VARCHAR(2) NOT NULL, - quantite INTEGER NOT NULL, - gencode VARCHAR(13) NOT NULL, - -- Informations client nom VARCHAR(255) NOT NULL, prenom VARCHAR(255) NOT NULL, @@ -87,19 +81,33 @@ CREATE TABLE IF NOT EXISTS sales ( photo_ticket TEXT ); +-- Table des produits vendus (nouveau) +CREATE TABLE IF NOT EXISTS sale_products ( + id SERIAL PRIMARY KEY, + sale_id INTEGER REFERENCES sales(id) ON DELETE CASCADE NOT NULL, + type_article VARCHAR(255) NOT NULL, + categorie VARCHAR(2) NOT NULL, + quantite INTEGER NOT NULL, + gencode VARCHAR(13) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- Index pour optimiser les recherches CREATE INDEX IF NOT EXISTS idx_sales_store_id ON sales(store_id); CREATE INDEX IF NOT EXISTS idx_sales_user_id ON sales(user_id); CREATE INDEX IF NOT EXISTS idx_sales_timestamp ON sales(timestamp); CREATE INDEX IF NOT EXISTS idx_sales_nom ON sales(nom); CREATE INDEX IF NOT EXISTS idx_sales_prenom ON sales(prenom); -CREATE INDEX IF NOT EXISTS idx_sales_gencode ON sales(gencode); + +-- Index pour la table des produits +CREATE INDEX IF NOT EXISTS idx_sale_products_sale_id ON sale_products(sale_id); +CREATE INDEX IF NOT EXISTS idx_sale_products_gencode ON sale_products(gencode); -- Relations et contraintes ALTER TABLE users ADD CONSTRAINT chk_user_role CHECK (role IN ('admin', 'manager', 'employee')); -ALTER TABLE sales ADD CONSTRAINT chk_sales_categorie +ALTER TABLE sale_products ADD CONSTRAINT chk_sale_products_categorie CHECK (categorie IN ('F2', 'F3')); -- Trigger pour mettre à jour updated_at automatiquement @@ -136,6 +144,7 @@ ON CONFLICT (username) DO NOTHING; SELECT setval('stores_id_seq', (SELECT COALESCE(MAX(id), 1) FROM stores)); SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users)); SELECT setval('sales_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sales)); +SELECT setval('sale_products_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sale_products)); -- Permissions et sécurité GRANT CONNECT ON DATABASE regisflow TO regisflow; diff --git a/migration-multi-products.sql b/migration-multi-products.sql new file mode 100644 index 0000000..a1f3e69 --- /dev/null +++ b/migration-multi-products.sql @@ -0,0 +1,42 @@ +-- Migration pour les ventes multi-produits +-- Cette migration convertit les ventes existantes vers le nouveau système + +-- 1. Créer la nouvelle table sale_products +CREATE TABLE IF NOT EXISTS sale_products ( + id SERIAL PRIMARY KEY, + sale_id INTEGER REFERENCES sales(id) ON DELETE CASCADE NOT NULL, + type_article VARCHAR(255) NOT NULL, + categorie VARCHAR(2) NOT NULL, + quantite INTEGER NOT NULL, + gencode VARCHAR(13) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Migrer les données existantes des ventes vers sale_products +INSERT INTO sale_products (sale_id, type_article, categorie, quantite, gencode) +SELECT + id, + type_article, + categorie, + quantite, + gencode +FROM sales +WHERE type_article IS NOT NULL; + +-- 3. Supprimer les colonnes produit de la table sales (après migration réussie) +-- Ces commandes seront exécutées après validation de la migration +-- ALTER TABLE sales DROP COLUMN type_article; +-- ALTER TABLE sales DROP COLUMN categorie; +-- ALTER TABLE sales DROP COLUMN quantite; +-- ALTER TABLE sales DROP COLUMN gencode; + +-- 4. Créer les index pour optimiser les recherches +CREATE INDEX IF NOT EXISTS idx_sale_products_sale_id ON sale_products(sale_id); +CREATE INDEX IF NOT EXISTS idx_sale_products_gencode ON sale_products(gencode); + +-- 5. Ajouter les contraintes +ALTER TABLE sale_products ADD CONSTRAINT chk_sale_products_categorie + CHECK (categorie IN ('F2', 'F3')); + +-- 6. Mettre à jour les séquences +SELECT setval('sale_products_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sale_products)); \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index b49fffb..c17e71f 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -243,9 +243,17 @@ export async function registerRoutes(app: Express): Promise { targetStoreId = user.storeId; } + // Extract products from request body + const { products = [], ...saleFields } = req.body; + + // Validate products + if (!Array.isArray(products) || products.length === 0) { + return res.status(400).json({ error: "Au moins un produit est requis" }); + } + // Map frontend camelCase to database snake_case for photos const mappedBody = { - ...req.body, + ...saleFields, }; // Add photo fields with proper mapping, only if they exist and are not empty @@ -270,7 +278,15 @@ export async function registerRoutes(app: Express): Promise { userId: user.id }); - const sale = await storage.createSale(saleData); + // Parse and validate products + const validatedProducts = products.map((product: any) => ({ + typeArticle: product.typeArticle, + categorie: product.categorie, + quantite: parseInt(product.quantite), + gencode: product.gencode + })); + + const sale = await storage.createSaleWithProducts(saleData, validatedProducts); res.status(201).json(sale); } catch (error) { console.error('Create sale error:', error); diff --git a/server/storage.ts b/server/storage.ts index 76501db..83406c4 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -2,12 +2,15 @@ import { users, stores, sales, + saleProducts, type User, type InsertUser, type Store, type InsertStore, type Sale, type InsertSale, + type SaleProduct, + type InsertSaleProduct, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc } from "drizzle-orm"; @@ -31,8 +34,9 @@ export interface IStorage { // Sale operations getSale(id: number): Promise; - createSale(sale: InsertSale): Promise; + createSaleWithProducts(sale: InsertSale, products: InsertSaleProduct[]): Promise; getSalesByStore(storeId: number, startDate?: string, endDate?: string): Promise; + getSaleWithProducts(id: number): Promise<(Sale & { products: SaleProduct[] }) | undefined>; deleteSale(id: number): Promise; // Auth operations @@ -136,12 +140,35 @@ export class DatabaseStorage implements IStorage { return sale; } - async createSale(saleData: InsertSale): Promise { - const [sale] = await db - .insert(sales) - .values(saleData) - .returning(); - return sale; + async createSaleWithProducts(saleData: InsertSale, products: InsertSaleProduct[]): Promise { + // Use transaction to ensure data consistency + return await db.transaction(async (tx) => { + // Create the sale first + const [sale] = await tx + .insert(sales) + .values(saleData) + .returning(); + + // Create all products for this sale + if (products.length > 0) { + await tx + .insert(saleProducts) + .values(products.map(product => ({ + ...product, + saleId: sale.id + }))); + } + + return sale; + }); + } + + async getSaleWithProducts(id: number): Promise<(Sale & { products: SaleProduct[] }) | undefined> { + const [sale] = await db.select().from(sales).where(eq(sales.id, id)); + if (!sale) return undefined; + + const products = await db.select().from(saleProducts).where(eq(saleProducts.saleId, id)); + return { ...sale, products }; } async getSalesByStore(storeId: number, startDate?: string, endDate?: string): Promise { @@ -161,10 +188,22 @@ export class DatabaseStorage implements IStorage { ); } - return await query.orderBy(desc(sales.timestamp)); + const salesData = await query.orderBy(desc(sales.timestamp)); + + // Get products for each sale + const salesWithProducts = await Promise.all( + salesData.map(async (sale) => { + const products = await db.select().from(saleProducts).where(eq(saleProducts.saleId, sale.id)); + return { ...sale, products }; + }) + ); + + return salesWithProducts; } async deleteSale(id: number): Promise { + // Delete sale products first (cascade should handle this, but being explicit) + await db.delete(saleProducts).where(eq(saleProducts.saleId, id)); await db.delete(sales).where(eq(sales.id, id)); } diff --git a/shared/schema.ts b/shared/schema.ts index 72d2199..375f991 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -51,7 +51,7 @@ export const users = pgTable("users", { updatedAt: timestamp("updated_at").defaultNow(), }); -// Sales table +// Sales table - Informations générales de la vente export const sales = pgTable("sales", { id: serial("id").primaryKey(), storeId: integer("store_id").references(() => stores.id).notNull(), @@ -61,12 +61,6 @@ export const sales = pgTable("sales", { // Vendeur vendeur: varchar("vendeur", { length: 255 }).notNull(), - // Produit - typeArticle: varchar("type_article", { length: 255 }).notNull(), - categorie: varchar("categorie", { length: 2 }).notNull(), - quantite: integer("quantite").notNull(), - gencode: varchar("gencode", { length: 13 }).notNull(), - // Client nom: varchar("nom", { length: 255 }).notNull(), prenom: varchar("prenom", { length: 255 }).notNull(), @@ -77,7 +71,7 @@ export const sales = pgTable("sales", { // Identité typeIdentite: varchar("type_identite", { length: 50 }).notNull(), numeroIdentite: varchar("numero_identite", { length: 100 }).notNull(), - autoriteDelivrance: varchar("autorite_delivrance", { length: 255 }).notNull(), + autoriteDelivrance: varchar("autorite_delivrance", { length: 255 }), dateDelivrance: varchar("date_delivrance", { length: 10 }).notNull(), // Photos @@ -86,6 +80,17 @@ export const sales = pgTable("sales", { photo_ticket: text("photo_ticket"), }); +// Table des produits vendus (nouveau) +export const saleProducts = pgTable("sale_products", { + id: serial("id").primaryKey(), + saleId: integer("sale_id").references(() => sales.id, { onDelete: "cascade" }).notNull(), + typeArticle: varchar("type_article", { length: 255 }).notNull(), + categorie: varchar("categorie", { length: 2 }).notNull(), + quantite: integer("quantite").notNull(), + gencode: varchar("gencode", { length: 13 }).notNull(), + createdAt: timestamp("created_at").defaultNow(), +}); + // Relations export const storesRelations = relations(stores, ({ many }) => ({ users: many(users), @@ -100,7 +105,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({ sales: many(sales), })); -export const salesRelations = relations(sales, ({ one }) => ({ +export const salesRelations = relations(sales, ({ one, many }) => ({ store: one(stores, { fields: [sales.storeId], references: [stores.id], @@ -109,6 +114,14 @@ export const salesRelations = relations(sales, ({ one }) => ({ fields: [sales.userId], references: [users.id], }), + products: many(saleProducts), +})); + +export const saleProductsRelations = relations(saleProducts, ({ one }) => ({ + sale: one(sales, { + fields: [saleProducts.saleId], + references: [sales.id], + }), })); // Insert schemas @@ -129,6 +142,11 @@ export const insertSaleSchema = createInsertSchema(sales).omit({ timestamp: true, }); +export const insertSaleProductSchema = createInsertSchema(saleProducts).omit({ + id: true, + createdAt: true, +}); + // Login schema export const loginSchema = z.object({ username: z.string().min(1, "Nom d'utilisateur requis"), @@ -142,4 +160,6 @@ export type Store = typeof stores.$inferSelect; export type InsertStore = typeof stores.$inferInsert; export type Sale = typeof sales.$inferSelect; export type InsertSale = typeof sales.$inferInsert; +export type SaleProduct = typeof saleProducts.$inferSelect; +export type InsertSaleProduct = typeof saleProducts.$inferInsert; export type LoginData = z.infer;