diff --git a/src/app/api/demo/seed/route.ts b/src/app/api/demo/seed/route.ts new file mode 100644 index 0000000..92eaf21 --- /dev/null +++ b/src/app/api/demo/seed/route.ts @@ -0,0 +1,272 @@ +import { NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { ModePaiement, StatutAtelier, StatutVente } from '@prisma/client' +import { db } from '@/lib/db' +import { getSession } from '@/lib/auth-utils' + +async function requireAdmin() { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: 'Non authentifie' }, { status: 401 }) + } + + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Acces reserve aux administrateurs' }, { status: 403 }) + } + + return null +} + +function htFromTtc(ttc: number, tva = 20) { + return ttc / (1 + tva / 100) +} + +export async function POST() { + const authError = await requireAdmin() + if (authError) return authError + + try { + const suppliers = await Promise.all([ + db.fournisseur.upsert({ + where: { id: 'demo-fournisseur-lumioptic' }, + update: { + nom: 'LumiOptic Distribution', + contact: 'Nadia Benali', + email: 'contact@demo-lumioptic.local', + telephone: '0522000001', + ville: 'Casablanca', + actif: true, + }, + create: { + id: 'demo-fournisseur-lumioptic', + nom: 'LumiOptic Distribution', + contact: 'Nadia Benali', + email: 'contact@demo-lumioptic.local', + telephone: '0522000001', + ville: 'Casablanca', + }, + }), + db.fournisseur.upsert({ + where: { id: 'demo-fournisseur-clairverre' }, + update: { + nom: 'ClairVerre Pro', + contact: 'Yassine El Amrani', + email: 'contact@demo-clairverre.local', + telephone: '0522000002', + ville: 'Rabat', + actif: true, + }, + create: { + id: 'demo-fournisseur-clairverre', + nom: 'ClairVerre Pro', + contact: 'Yassine El Amrani', + email: 'contact@demo-clairverre.local', + telephone: '0522000002', + ville: 'Rabat', + }, + }), + ]) + + const productsData = [ + ['DEMO-MNT-001', 'Monture acetate noir Atlas', 'MONTURE', 42, 129, 12, 4, 'Atlas', 'COMPLET', suppliers[0].id], + ['DEMO-MNT-002', 'Monture metal fine Sofia', 'MONTURE', 38, 115, 9, 3, 'Sofia', 'COMPLET', suppliers[0].id], + ['DEMO-MNT-003', 'Monture enfant Flex Kids', 'MONTURE', 25, 79, 14, 5, 'Flex Kids', 'NATUREL', suppliers[0].id], + ['DEMO-VR-001', 'Verres simple vision anti-reflet', 'VERRE', 18, 59, 40, 10, null, null, suppliers[1].id], + ['DEMO-VR-002', 'Verres progressifs confort', 'VERRE', 58, 189, 18, 6, null, null, suppliers[1].id], + ['DEMO-VR-003', 'Verres anti-lumiere bleue', 'VERRE', 24, 89, 22, 8, null, null, suppliers[1].id], + ['DEMO-LEN-001', 'Lentilles mensuelles confort', 'LENTILLE', 9, 29, 30, 8, 'ClearDay', null, suppliers[1].id], + ['DEMO-ACC-001', 'Kit nettoyage premium', 'ACCESSOIRE', 3, 12, 50, 12, null, null, suppliers[0].id], + ['DEMO-ACC-002', 'Etui rigide signature', 'ACCESSOIRE', 5, 18, 35, 10, null, null, suppliers[0].id], + ['DEMO-ACC-003', 'Cordon lunettes sport', 'ACCESSOIRE', 2, 9, 5, 10, null, null, suppliers[0].id], + ] as const + + const products = await Promise.all( + productsData.map(([reference, designation, categorie, prixAchatHT, prixVenteTTC, stock, stockMin, marque, typeMonture, fournisseurId]) => + db.produit.upsert({ + where: { reference }, + update: { + designation, + categorie, + prixAchatHT, + prixVenteTTC, + stock, + stockMin, + marque, + typeMonture: typeMonture as any, + fournisseurId, + actif: true, + }, + create: { + reference, + designation, + categorie, + prixAchatHT, + prixVenteTTC, + tva: 20, + stock, + stockMin, + marque, + typeMonture: typeMonture as any, + fournisseurId, + actif: true, + }, + }) + ) + ) + + const clientsData = [ + ['demo-client-1', 'Amina', 'Rachid', '0600000101', 'amina.rachid@demo.local', 'Casablanca'], + ['demo-client-2', 'Karim', 'Bennani', '0600000102', 'karim.bennani@demo.local', 'Rabat'], + ['demo-client-3', 'Sara', 'El Fassi', '0600000103', 'sara.elfassi@demo.local', 'Marrakech'], + ['demo-client-4', 'Youssef', 'Idrissi', '0600000104', null, 'Tanger'], + ['demo-client-5', 'Leila', 'Mansouri', '0600000105', 'leila.mansouri@demo.local', 'Fes'], + ] as const + + const clients = await Promise.all( + clientsData.map(([id, prenom, nom, telephone, email, ville]) => + db.client.upsert({ + where: { telephone }, + update: { + prenom, + nom, + email, + ville, + }, + create: { + id, + prenom, + nom, + telephone, + email, + ville, + }, + }) + ) + ) + + const password = await bcrypt.hash('demo123', 12) + const employees = await Promise.all([ + db.employe.upsert({ + where: { email: 'vendeur.demo@optiquestock.local' }, + update: { + nom: 'Demo', + prenom: 'Vendeur', + role: 'VENDEUR', + actif: true, + }, + create: { + email: 'vendeur.demo@optiquestock.local', + nom: 'Demo', + prenom: 'Vendeur', + role: 'VENDEUR', + actif: true, + motDePasse: password, + }, + }), + db.employe.upsert({ + where: { email: 'responsable.demo@optiquestock.local' }, + update: { + nom: 'Demo', + prenom: 'Responsable', + role: 'RESPONSABLE', + actif: true, + }, + create: { + email: 'responsable.demo@optiquestock.local', + nom: 'Demo', + prenom: 'Responsable', + role: 'RESPONSABLE', + actif: true, + motDePasse: password, + }, + }), + ]) + + const saleSpecs = [ + { + numero: 'DEMO-VENTE-001', + client: clients[0], + statutAtelier: StatutAtelier.EN_COURS, + items: [products[0], products[3], products[7]], + payment: ModePaiement.CARTE, + }, + { + numero: 'DEMO-VENTE-002', + client: clients[1], + statutAtelier: StatutAtelier.PRET, + items: [products[1], products[4]], + payment: ModePaiement.ESPECES, + }, + { + numero: 'DEMO-VENTE-003', + client: clients[2], + statutAtelier: StatutAtelier.EN_ATTENTE, + items: [products[2], products[5]], + payment: ModePaiement.CHEQUE, + }, + ] + + let salesCreated = 0 + for (const spec of saleSpecs) { + const existingSale = await db.vente.findUnique({ where: { numero: spec.numero } }) + if (existingSale) continue + + const montantTTC = spec.items.reduce((sum, product) => sum + product.prixVenteTTC, 0) + const montantHT = spec.items.reduce((sum, product) => sum + htFromTtc(product.prixVenteTTC), 0) + + await db.vente.create({ + data: { + numero: spec.numero, + clientId: spec.client.id, + employeId: employees[0].id, + statut: StatutVente.PAYEE, + statutAtelier: spec.statutAtelier, + montantHT, + montantTVA: montantTTC - montantHT, + montantTTC, + notes: 'Vente demo generee automatiquement', + dateAtelier: new Date(), + lignes: { + create: spec.items.map((product) => ({ + produitId: product.id, + quantite: 1, + prixUnitaireHT: htFromTtc(product.prixVenteTTC), + prixUnitaireTTC: product.prixVenteTTC, + remise: 0, + montantHT: htFromTtc(product.prixVenteTTC), + montantTTC: product.prixVenteTTC, + })), + }, + paiements: { + create: { + mode: spec.payment, + montant: montantTTC, + employeId: employees[0].id, + reference: spec.numero, + }, + }, + }, + }) + salesCreated += 1 + } + + return NextResponse.json({ + message: 'Demo data ready', + clients: clients.length, + products: products.length, + suppliers: suppliers.length, + employees: employees.length, + salesCreated, + demoLogins: [ + { email: 'vendeur.demo@optiquestock.local', password: 'demo123' }, + { email: 'responsable.demo@optiquestock.local', password: 'demo123' }, + ], + }) + } catch (error) { + console.error('Demo seed error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to seed demo data' }, + { status: 500 } + ) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e15fdd7..1492db9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { Toaster } from "@/components/ui/toaster"; import SessionProvider from "@/components/auth/SessionProvider"; import { ThemeProvider } from "@/components/theme-provider"; +import { CurrencyProvider } from "@/components/currency-provider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -46,9 +47,11 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`} > - - {children} - + + + {children} + + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5a6cd6a..87be9a6 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -29,6 +29,8 @@ function LoginForm() { setError('') setLoading(true) + await requestFullscreen() + const callbackUrl = getLocalCallbackPath(searchParams.get('callbackUrl')) const result = await signIn('credentials', { email, @@ -96,6 +98,11 @@ function LoginForm() { ) } +async function requestFullscreen() { + if (document.fullscreenElement || !document.documentElement.requestFullscreen) return + await document.documentElement.requestFullscreen().catch(() => undefined) +} + function getLocalCallbackPath(callbackUrl: string | null) { if (!callbackUrl) return '/' diff --git a/src/app/page.tsx b/src/app/page.tsx index af8bda1..83f36f6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,8 +9,10 @@ import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/ca import { BarChart3, BrainCircuit, + Database, Eye, LayoutDashboard, + Loader2, LogOut, Package, PanelTop, @@ -32,6 +34,7 @@ import { EmployeeManagement } from '@/components/employees/EmployeeManagement' import { AIAssistant } from '@/components/ai/AIAssistant' import { ThemeToggle } from '@/components/theme-toggle' import { SellerWizard } from '@/components/seller-wizard/SellerWizard' +import { CurrencySelect } from '@/components/currency-select' type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN' type Module = @@ -149,6 +152,8 @@ export default function Home() { const { data: session, status } = useSession() const router = useRouter() const [currentModule, setCurrentModule] = useState('HOME') + const [demoGenerating, setDemoGenerating] = useState(false) + const [demoMessage, setDemoMessage] = useState('') const currentRole = ((session?.user as any)?.role || 'VENDEUR') as RoleEmploye const visibleModules = modules.filter((module) => module.roles.includes(currentRole)) @@ -166,6 +171,28 @@ export default function Home() { } }, [currentModule, currentRole]) + async function generateDemoData() { + setDemoGenerating(true) + setDemoMessage('') + + try { + const response = await fetch('/api/demo/seed', { method: 'POST' }) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Generation demo impossible') + } + + setDemoMessage( + `Demo prete: ${data.clients} clients, ${data.products} produits, ${data.salesCreated} nouvelles ventes.` + ) + } catch (error) { + setDemoMessage(error instanceof Error ? error.message : 'Erreur demo inconnue') + } finally { + setDemoGenerating(false) + } + } + if (status === 'loading') { return (
@@ -206,6 +233,26 @@ export default function Home() {

+ {currentRole === 'ADMIN' && ( +
+ + {demoMessage &&

{demoMessage}

} +
+ )} +
{visibleModules.map((module) => (
+
{session?.user?.name} diff --git a/src/components/atelier/AtelierModule.tsx b/src/components/atelier/AtelierModule.tsx index e01da4e..4059ddf 100644 --- a/src/components/atelier/AtelierModule.tsx +++ b/src/components/atelier/AtelierModule.tsx @@ -241,35 +241,6 @@ export default function AtelierModule() { } } - // Seed sample data - const seedSampleData = async () => { - try { - const response = await fetch('/api/atelier/seed?XTransformPort=3000', { - method: 'POST' - }) - if (response.ok) { - toast({ - title: 'Données ajoutées', - description: 'Les données de test ont été créées avec succès' - }) - loadWorkOrders() - } else { - const error = await response.json() - toast({ - title: 'Erreur', - description: error.error || 'Impossible de créer les données de test', - variant: 'destructive' - }) - } - } catch (error) { - console.error('Error seeding data:', error) - toast({ - title: 'Erreur', - description: 'Impossible de créer les données de test', - variant: 'destructive' - }) - } - } useEffect(() => { loadWorkOrders() @@ -480,12 +451,6 @@ export default function AtelierModule() { : `Aucune commande avec le statut "${statusFilter === 'EN_ATTENTE' ? 'En attente' : statusFilter === 'EN_COURS' ? 'En cours' : statusFilter === 'TERMINE' ? 'Terminé' : 'Prêt'}"` }

- {statusFilter === 'ALL' && workOrders.length === 0 && ( - - )}
) : ( diff --git a/src/components/currency-provider.tsx b/src/components/currency-provider.tsx new file mode 100644 index 0000000..d5bfc97 --- /dev/null +++ b/src/components/currency-provider.tsx @@ -0,0 +1,57 @@ +'use client' + +import { createContext, useContext, useEffect, useMemo, useState } from 'react' + +export type CurrencyCode = 'MAD' | 'EUR' | 'USD' + +const currencyLocales: Record = { + MAD: 'fr-MA', + EUR: 'fr-FR', + USD: 'en-US', +} + +interface CurrencyContextValue { + currency: CurrencyCode + setCurrency: (currency: CurrencyCode) => void + formatCurrency: (value: number) => string +} + +const CurrencyContext = createContext(null) + +export function CurrencyProvider({ children }: { children: React.ReactNode }) { + const [currency, setCurrencyState] = useState('MAD') + + useEffect(() => { + const stored = window.localStorage.getItem('optiquestock-currency') as CurrencyCode | null + if (stored === 'MAD' || stored === 'EUR' || stored === 'USD') { + setCurrencyState(stored) + } + }, []) + + const value = useMemo(() => { + return { + currency, + setCurrency(nextCurrency) { + setCurrencyState(nextCurrency) + window.localStorage.setItem('optiquestock-currency', nextCurrency) + }, + formatCurrency(amount) { + return new Intl.NumberFormat(currencyLocales[currency], { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(amount) + }, + } + }, [currency]) + + return {children} +} + +export function useCurrency() { + const context = useContext(CurrencyContext) + if (!context) { + throw new Error('useCurrency must be used within CurrencyProvider') + } + return context +} diff --git a/src/components/currency-select.tsx b/src/components/currency-select.tsx new file mode 100644 index 0000000..bff28a3 --- /dev/null +++ b/src/components/currency-select.tsx @@ -0,0 +1,25 @@ +'use client' + +import { BadgeDollarSign } from 'lucide-react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { CurrencyCode, useCurrency } from '@/components/currency-provider' + +export function CurrencySelect() { + const { currency, setCurrency } = useCurrency() + + return ( +
+ + +
+ ) +} diff --git a/src/components/seller-wizard/SellerWizard.tsx b/src/components/seller-wizard/SellerWizard.tsx index 54fe91f..3a385aa 100644 --- a/src/components/seller-wizard/SellerWizard.tsx +++ b/src/components/seller-wizard/SellerWizard.tsx @@ -28,6 +28,7 @@ import { Wrench, X, } from 'lucide-react' +import { useCurrency } from '@/components/currency-provider' type Step = 'client' | 'service' | 'details' | 'final' type ServiceType = 'COMBO' | 'REPAIR' @@ -104,11 +105,8 @@ const paymentLabels: Record = { BON_CAISSE: 'Bon de caisse', } -function currency(value: number) { - return `${value.toFixed(2)} EUR` -} - export function SellerWizard() { + const { formatCurrency } = useCurrency() const [step, setStep] = useState('client') const [clients, setClients] = useState([]) const [products, setProducts] = useState([]) @@ -266,18 +264,26 @@ export function SellerWizard() { setError('') if (clientFieldIndex < clientFields.length - 1) { setClientFieldIndex((index) => index + 1) - focusActiveClientInput() + holdKeyboardOpen() } else { createClient() } } function focusActiveClientInput() { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - activeClientInputRef.current?.focus({ preventScroll: true }) - }) - }) + activeClientInputRef.current?.focus({ preventScroll: true }) + } + + function holdKeyboardOpen() { + focusActiveClientInput() + window.requestAnimationFrame(focusActiveClientInput) + window.setTimeout(focusActiveClientInput, 40) + window.setTimeout(focusActiveClientInput, 120) + } + + function keepTouchOnInput(event: React.MouseEvent | React.PointerEvent | React.TouchEvent) { + event.preventDefault() + holdKeyboardOpen() } async function createClient() { @@ -440,7 +446,7 @@ export function SellerWizard() { nextClientField() } else { setClientFieldIndex((index) => Math.max(0, index - 1)) - focusActiveClientInput() + holdKeyboardOpen() } } @@ -582,6 +588,11 @@ export function SellerWizard() { nextClientField() } }} + onBlur={() => { + if (createClientOpen && !loading) { + holdKeyboardOpen() + } + }} className={`${ keyboardOpen ? 'h-16 text-2xl' : 'h-20 text-3xl' } min-w-0`} @@ -589,7 +600,11 @@ export function SellerWizard() { />
-
{currency(product.prixVenteTTC)}
+
{formatCurrency(product.prixVenteTTC)}
))} @@ -798,7 +821,7 @@ export function SellerWizard() {
Total
-
{currency(total)}
+
{formatCurrency(total)}
@@ -839,21 +862,21 @@ export function SellerWizard() { {line.quantite} x {line.produit.designation} - {currency(line.montantTTC)} + {formatCurrency(line.montantTTC)}
))} {sale.notes &&

{sale.notes}

}
HT - {currency(sale.montantHT)} + {formatCurrency(sale.montantHT)}
TVA - {currency(sale.montantTVA)} + {formatCurrency(sale.montantTVA)}
Total TTC - {currency(sale.montantTTC)} + {formatCurrency(sale.montantTTC)}

Paiement: {paymentLabels[sale.paiements[0]?.mode || 'ESPECES']}