Add tablet seller wizard and access controls
This commit is contained in:
116
src/components/ai/AIAssistant.tsx
Normal file
116
src/components/ai/AIAssistant.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bot, Loader2, Send, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
const quickPrompts = [
|
||||
'Resume les priorites du magasin pour aujourd hui.',
|
||||
'Quels produits dois-je recommander de reapprovisionner ?',
|
||||
'Donne-moi 5 idees pour ameliorer les ventes cette semaine.',
|
||||
]
|
||||
|
||||
export function AIAssistant() {
|
||||
const [prompt, setPrompt] = useState(quickPrompts[0])
|
||||
const [answer, setAnswer] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function askAssistant() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setAnswer('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/assistant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Assistant IA indisponible')
|
||||
}
|
||||
|
||||
setAnswer(data.answer)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-cyan-500 p-3 text-white">
|
||||
<Sparkles className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Assistant IA</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Analyse le contexte du magasin et propose des actions rapides.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Demandes rapides</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{quickPrompts.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={prompt === item ? 'default' : 'outline'}
|
||||
className="h-auto w-full justify-start whitespace-normal text-left"
|
||||
onClick={() => setPrompt(item)}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-4 w-4" />
|
||||
Conseiller OptiqueStock
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
className="min-h-32"
|
||||
placeholder="Posez une question sur le stock, les ventes, les priorites..."
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={askAssistant} disabled={loading || !prompt.trim()} className="gap-2">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Demander
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answer && (
|
||||
<div className="rounded-md border bg-muted/40 p-4">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
468
src/components/employees/EmployeeManagement.tsx
Normal file
468
src/components/employees/EmployeeManagement.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Plus, Search, Shield, Trash2, UserCog, Users } from 'lucide-react'
|
||||
|
||||
type RoleEmploye = 'VENDEUR' | 'RESPONSABLE' | 'ADMIN'
|
||||
|
||||
interface Employee {
|
||||
id: string
|
||||
email: string
|
||||
nom: string
|
||||
prenom: string
|
||||
role: RoleEmploye
|
||||
actif: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count?: {
|
||||
ventes: number
|
||||
facturesAchat: number
|
||||
paiements: number
|
||||
patients: number
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabels: Record<RoleEmploye, string> = {
|
||||
VENDEUR: 'Vendeur',
|
||||
RESPONSABLE: 'Responsable',
|
||||
ADMIN: 'Administrateur',
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
email: '',
|
||||
nom: '',
|
||||
prenom: '',
|
||||
role: 'VENDEUR' as RoleEmploye,
|
||||
actif: true,
|
||||
password: '',
|
||||
}
|
||||
|
||||
export function EmployeeManagement() {
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployees()
|
||||
}, [])
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return employees.filter((employee) => {
|
||||
const matchesSearch =
|
||||
!term ||
|
||||
employee.nom.toLowerCase().includes(term) ||
|
||||
employee.prenom.toLowerCase().includes(term) ||
|
||||
employee.email.toLowerCase().includes(term) ||
|
||||
roleLabels[employee.role].toLowerCase().includes(term)
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'active' && employee.actif) ||
|
||||
(statusFilter === 'inactive' && !employee.actif)
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [employees, searchTerm, statusFilter])
|
||||
|
||||
const activeCount = employees.filter((employee) => employee.actif).length
|
||||
const adminCount = employees.filter((employee) => employee.actif && employee.role === 'ADMIN').length
|
||||
|
||||
async function fetchEmployees() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/employes')
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Erreur lors du chargement des employes')
|
||||
}
|
||||
|
||||
setEmployees(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingEmployee(null)
|
||||
setForm(emptyForm)
|
||||
setError('')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(employee: Employee) {
|
||||
setEditingEmployee(employee)
|
||||
setForm({
|
||||
email: employee.email,
|
||||
nom: employee.nom,
|
||||
prenom: employee.prenom,
|
||||
role: employee.role,
|
||||
actif: employee.actif,
|
||||
password: '',
|
||||
})
|
||||
setError('')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function saveEmployee(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: form.email,
|
||||
nom: form.nom,
|
||||
prenom: form.prenom,
|
||||
role: form.role,
|
||||
actif: form.actif,
|
||||
}
|
||||
|
||||
if (form.password) {
|
||||
payload.password = form.password
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
editingEmployee ? `/api/employes/${editingEmployee.id}` : '/api/employes',
|
||||
{
|
||||
method: editingEmployee ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Enregistrement impossible')
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
setEditingEmployee(null)
|
||||
setForm(emptyForm)
|
||||
await fetchEmployees()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEmployee(employee: Employee) {
|
||||
if (!confirm(`Supprimer ou desactiver ${employee.prenom} ${employee.nom} ?`)) return
|
||||
|
||||
setError('')
|
||||
const response = await fetch(`/api/employes/${employee.id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Suppression impossible')
|
||||
return
|
||||
}
|
||||
|
||||
await fetchEmployees()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Utilisateurs et acces</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gere les employes, leur statut et leur niveau d'acces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreateDialog} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvel employe
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingEmployee ? 'Modifier un employe' : 'Nouvel employe'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Les administrateurs peuvent gerer les utilisateurs et les acces.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={saveEmployee} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prenom">Prenom</Label>
|
||||
<Input
|
||||
id="prenom"
|
||||
value={form.prenom}
|
||||
onChange={(event) => setForm({ ...form, prenom: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={form.nom}
|
||||
onChange={(event) => setForm({ ...form, nom: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{editingEmployee ? 'Nouveau mot de passe' : 'Mot de passe'}
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||
required={!editingEmployee}
|
||||
placeholder={editingEmployee ? 'Laisser vide pour conserver' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Niveau d'acces</Label>
|
||||
<Select
|
||||
value={form.role}
|
||||
onValueChange={(value: RoleEmploye) => setForm({ ...form, role: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="VENDEUR">Vendeur</SelectItem>
|
||||
<SelectItem value="RESPONSABLE">Responsable</SelectItem>
|
||||
<SelectItem value="ADMIN">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<Label>Compte actif</Label>
|
||||
<p className="text-xs text-muted-foreground">Autorise la connexion</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.actif}
|
||||
onCheckedChange={(checked) => setForm({ ...form, actif: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
<p className="text-2xl font-bold">{employees.length}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Comptes actifs</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{activeCount}</p>
|
||||
</div>
|
||||
<UserCog className="h-8 w-8 text-emerald-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Administrateurs</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{adminCount}</p>
|
||||
</div>
|
||||
<Shield className="h-8 w-8 text-blue-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{error && !dialogOpen && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recherche et filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher par nom, email ou role..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={statusFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('all')}
|
||||
>
|
||||
Tous
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'active' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('active')}
|
||||
>
|
||||
Actifs
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'inactive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
>
|
||||
Inactifs
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[560px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Employe</TableHead>
|
||||
<TableHead>Acces</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Activite liee</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center">
|
||||
Chargement...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEmployees.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
Aucun employe trouve
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredEmployees.map((employee) => (
|
||||
<TableRow key={employee.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{employee.prenom} {employee.nom}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{employee.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={employee.role === 'ADMIN' ? 'default' : 'secondary'}>
|
||||
{roleLabels[employee.role]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={employee.actif ? 'default' : 'secondary'}>
|
||||
{employee.actif ? 'Actif' : 'Inactif'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{(employee._count?.ventes || 0) + (employee._count?.facturesAchat || 0)} operations
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditDialog(employee)}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteEmployee(employee)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
852
src/components/seller-wizard/SellerWizard.tsx
Normal file
852
src/components/seller-wizard/SellerWizard.tsx
Normal file
@@ -0,0 +1,852 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Expand,
|
||||
FileText,
|
||||
Package,
|
||||
Plus,
|
||||
Printer,
|
||||
Search,
|
||||
User,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
type Step = 'client' | 'service' | 'details' | 'final'
|
||||
type ServiceType = 'COMBO' | 'REPAIR'
|
||||
type PaymentMode = 'ESPECES' | 'CARTE' | 'CHEQUE' | 'VIREMENT' | 'BON_CAISSE'
|
||||
|
||||
interface Client {
|
||||
id: string
|
||||
nom: string
|
||||
prenom: string
|
||||
telephone: string
|
||||
email?: string | null
|
||||
adresse?: string | null
|
||||
ville?: string | null
|
||||
codePostal?: string | null
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
reference: string
|
||||
designation: string
|
||||
categorie: string
|
||||
prixVenteTTC: number
|
||||
tva: number
|
||||
stock: number
|
||||
marque?: string | null
|
||||
}
|
||||
|
||||
interface SelectedProduct {
|
||||
produitId: string
|
||||
quantite: number
|
||||
prixUnitaireTTC: number
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Sale {
|
||||
id: string
|
||||
numero: string
|
||||
date: string
|
||||
montantHT: number
|
||||
montantTVA: number
|
||||
montantTTC: number
|
||||
notes?: string | null
|
||||
client?: Client | null
|
||||
lignes: Array<{
|
||||
quantite: number
|
||||
prixUnitaireTTC: number
|
||||
montantTTC: number
|
||||
produit: {
|
||||
designation: string
|
||||
reference: string
|
||||
}
|
||||
}>
|
||||
paiements: Array<{
|
||||
mode: PaymentMode
|
||||
montant: number
|
||||
}>
|
||||
}
|
||||
|
||||
const clientFields = [
|
||||
{ key: 'prenom', label: 'Prenom', required: true, inputMode: 'text' },
|
||||
{ key: 'nom', label: 'Nom', required: true, inputMode: 'text' },
|
||||
{ key: 'telephone', label: 'Telephone', required: true, inputMode: 'tel' },
|
||||
{ key: 'email', label: 'Email', required: false, inputMode: 'email' },
|
||||
{ key: 'adresse', label: 'Adresse', required: false, inputMode: 'text' },
|
||||
{ key: 'ville', label: 'Ville', required: false, inputMode: 'text' },
|
||||
{ key: 'codePostal', label: 'Code postal', required: false, inputMode: 'numeric' },
|
||||
] as const
|
||||
|
||||
const paymentLabels: Record<PaymentMode, string> = {
|
||||
ESPECES: 'Especes',
|
||||
CARTE: 'Carte',
|
||||
CHEQUE: 'Cheque',
|
||||
VIREMENT: 'Virement',
|
||||
BON_CAISSE: 'Bon de caisse',
|
||||
}
|
||||
|
||||
function currency(value: number) {
|
||||
return `${value.toFixed(2)} EUR`
|
||||
}
|
||||
|
||||
export function SellerWizard() {
|
||||
const [step, setStep] = useState<Step>('client')
|
||||
const [clients, setClients] = useState<Client[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [clientSearch, setClientSearch] = useState('')
|
||||
const [productSearch, setProductSearch] = useState('')
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null)
|
||||
const [serviceType, setServiceType] = useState<ServiceType | null>(null)
|
||||
const [repairType, setRepairType] = useState('')
|
||||
const [repairDescription, setRepairDescription] = useState('')
|
||||
const [repairPrice, setRepairPrice] = useState(0)
|
||||
const [comboDetails, setComboDetails] = useState('')
|
||||
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
|
||||
const [additionalCharges, setAdditionalCharges] = useState(0)
|
||||
const [paymentMode, setPaymentMode] = useState<PaymentMode>('ESPECES')
|
||||
const [sale, setSale] = useState<Sale | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [createClientOpen, setCreateClientOpen] = useState(false)
|
||||
const [clientFieldIndex, setClientFieldIndex] = useState(0)
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false)
|
||||
const [newClient, setNewClient] = useState<Record<string, string>>({
|
||||
prenom: '',
|
||||
nom: '',
|
||||
telephone: '',
|
||||
email: '',
|
||||
adresse: '',
|
||||
ville: '',
|
||||
codePostal: '',
|
||||
})
|
||||
const touchStart = useRef<{ x: number; y: number; time: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadClients()
|
||||
loadProducts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createClientOpen) {
|
||||
setKeyboardOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
const viewport = window.visualViewport
|
||||
const updateViewport = () => {
|
||||
const height = viewport?.height || window.innerHeight
|
||||
const offsetTop = viewport?.offsetTop || 0
|
||||
const keyboardHeight = Math.max(0, window.innerHeight - height - offsetTop)
|
||||
root.style.setProperty('--seller-wizard-vh', `${height}px`)
|
||||
root.style.setProperty('--seller-wizard-keyboard', `${keyboardHeight}px`)
|
||||
setKeyboardOpen(keyboardHeight > 120)
|
||||
}
|
||||
|
||||
updateViewport()
|
||||
viewport?.addEventListener('resize', updateViewport)
|
||||
viewport?.addEventListener('scroll', updateViewport)
|
||||
window.addEventListener('orientationchange', updateViewport)
|
||||
|
||||
return () => {
|
||||
viewport?.removeEventListener('resize', updateViewport)
|
||||
viewport?.removeEventListener('scroll', updateViewport)
|
||||
window.removeEventListener('orientationchange', updateViewport)
|
||||
root.style.removeProperty('--seller-wizard-vh')
|
||||
root.style.removeProperty('--seller-wizard-keyboard')
|
||||
}
|
||||
}, [createClientOpen])
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
const query = clientSearch.toLowerCase().trim()
|
||||
if (!query) return clients.slice(0, 20)
|
||||
return clients
|
||||
.filter((client) =>
|
||||
`${client.prenom} ${client.nom} ${client.telephone} ${client.email || ''}`
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
)
|
||||
.slice(0, 30)
|
||||
}, [clients, clientSearch])
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
const query = productSearch.toLowerCase().trim()
|
||||
return products
|
||||
.filter((product) => {
|
||||
const text = `${product.reference} ${product.designation} ${product.categorie} ${product.marque || ''}`.toLowerCase()
|
||||
return !query || text.includes(query)
|
||||
})
|
||||
.slice(0, 24)
|
||||
}, [products, productSearch])
|
||||
|
||||
const totalProducts = selectedProducts.reduce(
|
||||
(sum, item) => sum + item.prixUnitaireTTC * item.quantite,
|
||||
0
|
||||
)
|
||||
const total =
|
||||
serviceType === 'REPAIR'
|
||||
? repairPrice + additionalCharges
|
||||
: totalProducts + additionalCharges
|
||||
|
||||
async function loadClients() {
|
||||
const response = await fetch('/api/clients')
|
||||
if (response.ok) setClients(await response.json())
|
||||
}
|
||||
|
||||
async function loadProducts() {
|
||||
const response = await fetch('/api/pos/products')
|
||||
if (response.ok) setProducts(await response.json())
|
||||
}
|
||||
|
||||
function chooseClient(client: Client) {
|
||||
setSelectedClient(client)
|
||||
setError('')
|
||||
setStep('service')
|
||||
}
|
||||
|
||||
function addProduct(product: Product) {
|
||||
setSelectedProducts((current) => {
|
||||
const existing = current.find((item) => item.produitId === product.id)
|
||||
if (existing) {
|
||||
return current.map((item) =>
|
||||
item.produitId === product.id
|
||||
? { ...item, quantite: Math.min(product.stock, item.quantite + 1) }
|
||||
: item
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
produitId: product.id,
|
||||
quantite: 1,
|
||||
prixUnitaireTTC: product.prixVenteTTC,
|
||||
label: `${product.designation} (${product.reference})`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function removeProduct(productId: string) {
|
||||
setSelectedProducts((current) => current.filter((item) => item.produitId !== productId))
|
||||
}
|
||||
|
||||
function nextClientField() {
|
||||
const field = clientFields[clientFieldIndex]
|
||||
if (field.required && !newClient[field.key]?.trim()) {
|
||||
setError(`${field.label} est obligatoire`)
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
if (clientFieldIndex < clientFields.length - 1) {
|
||||
setClientFieldIndex((index) => index + 1)
|
||||
} else {
|
||||
createClient()
|
||||
}
|
||||
}
|
||||
|
||||
async function createClient() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/clients', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newClient),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) throw new Error(data.error || 'Creation client impossible')
|
||||
|
||||
setClients((current) => [data, ...current])
|
||||
setSelectedClient(data)
|
||||
setCreateClientOpen(false)
|
||||
setClientFieldIndex(0)
|
||||
setNewClient({
|
||||
prenom: '',
|
||||
nom: '',
|
||||
telephone: '',
|
||||
email: '',
|
||||
adresse: '',
|
||||
ville: '',
|
||||
codePostal: '',
|
||||
})
|
||||
setStep('service')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSale() {
|
||||
if (!selectedClient) {
|
||||
setError('Selectionnez un client')
|
||||
setStep('client')
|
||||
return
|
||||
}
|
||||
|
||||
if (serviceType === 'COMBO' && selectedProducts.length === 0) {
|
||||
setError('Ajoutez au moins une monture ou un verre')
|
||||
setStep('details')
|
||||
return
|
||||
}
|
||||
|
||||
if (serviceType === 'REPAIR' && (!repairType.trim() || !repairDescription.trim())) {
|
||||
setError('Completez le type et la description de reparation')
|
||||
setStep('details')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pos/wizard-sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientId: selectedClient.id,
|
||||
serviceType,
|
||||
products: selectedProducts,
|
||||
details: comboDetails,
|
||||
repairType,
|
||||
repairDescription,
|
||||
repairPrice,
|
||||
additionalCharges,
|
||||
paymentMode,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) throw new Error(data.error || 'Vente impossible')
|
||||
|
||||
setSale(data)
|
||||
setStep('final')
|
||||
loadProducts()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
setStep('client')
|
||||
setSelectedClient(null)
|
||||
setServiceType(null)
|
||||
setRepairType('')
|
||||
setRepairDescription('')
|
||||
setRepairPrice(0)
|
||||
setComboDetails('')
|
||||
setSelectedProducts([])
|
||||
setAdditionalCharges(0)
|
||||
setPaymentMode('ESPECES')
|
||||
setSale(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
function printReceipt() {
|
||||
const receipt = document.getElementById('seller-wizard-receipt')?.innerHTML
|
||||
if (!receipt) return
|
||||
|
||||
const printWindow = window.open('', '_blank', 'width=420,height=680')
|
||||
if (!printWindow) return
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Recu ${sale?.numero || ''}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 16px; color: #111; }
|
||||
.receipt { max-width: 360px; margin: 0 auto; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; margin: 8px 0; }
|
||||
.total { font-size: 20px; font-weight: 700; border-top: 1px solid #111; padding-top: 12px; }
|
||||
.muted { color: #555; font-size: 12px; }
|
||||
h1, h2, p { margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${receipt}<script>window.print(); window.close();</script></body>
|
||||
</html>
|
||||
`)
|
||||
printWindow.document.close()
|
||||
}
|
||||
|
||||
async function enterFullscreen() {
|
||||
const target = document.documentElement
|
||||
if (!document.fullscreenElement && target.requestFullscreen) {
|
||||
await target.requestFullscreen().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchStart(event: React.TouchEvent) {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
touchStart.current = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd(event: React.TouchEvent) {
|
||||
const start = touchStart.current
|
||||
const touch = event.changedTouches[0]
|
||||
if (!start || !touch) return
|
||||
|
||||
const diffX = touch.clientX - start.x
|
||||
const diffY = touch.clientY - start.y
|
||||
const elapsed = Date.now() - start.time
|
||||
const isHorizontal = Math.abs(diffX) > 56 && Math.abs(diffX) > Math.abs(diffY) * 1.4
|
||||
|
||||
if (isHorizontal && elapsed < 800) {
|
||||
if (diffX > 0) {
|
||||
nextClientField()
|
||||
} else {
|
||||
setClientFieldIndex((index) => Math.max(0, index - 1))
|
||||
}
|
||||
}
|
||||
|
||||
touchStart.current = null
|
||||
}
|
||||
|
||||
const currentField = clientFields[clientFieldIndex]
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-4 text-base">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="lg" onClick={enterFullscreen} className="h-12 gap-2">
|
||||
<Expand className="h-5 w-5" />
|
||||
Plein ecran
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(['client', 'service', 'details', 'final'] as Step[]).map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className={`h-3 rounded-full ${step === item ? 'bg-primary' : index < ['client', 'service', 'details', 'final'].indexOf(step) ? 'bg-emerald-500' : 'bg-muted'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-lg text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'client' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||
<User className="h-8 w-8" />
|
||||
Choisir le client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
className="h-20 text-xl"
|
||||
onClick={() => setCreateClientOpen(false)}
|
||||
variant={!createClientOpen ? 'default' : 'outline'}
|
||||
>
|
||||
Client existant
|
||||
</Button>
|
||||
<Button
|
||||
className="h-20 text-xl"
|
||||
onClick={() => {
|
||||
setCreateClientOpen(true)
|
||||
setError('')
|
||||
}}
|
||||
variant={createClientOpen ? 'default' : 'outline'}
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
Nouveau client
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!createClientOpen && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={clientSearch}
|
||||
onChange={(event) => setClientSearch(event.target.value)}
|
||||
placeholder="Nom, telephone, email..."
|
||||
className="h-16 pl-12 text-xl"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{filteredClients.map((client) => (
|
||||
<Button
|
||||
key={client.id}
|
||||
variant="outline"
|
||||
className="h-auto justify-start p-5 text-left"
|
||||
onClick={() => chooseClient(client)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xl font-semibold">
|
||||
{client.prenom} {client.nom}
|
||||
</div>
|
||||
<div className="text-base text-muted-foreground">{client.telephone}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{createClientOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background p-4 [height:var(--seller-wizard-vh,100dvh)] sm:p-5"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<Button variant="ghost" size="lg" onClick={() => setCreateClientOpen(false)}>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" onClick={enterFullscreen} className="h-12">
|
||||
<Expand className="h-5 w-5" />
|
||||
</Button>
|
||||
<Badge variant="secondary" className="px-4 py-2 text-base">
|
||||
{clientFieldIndex + 1}/{clientFields.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex min-h-0 flex-1 flex-col ${
|
||||
keyboardOpen ? 'justify-start pt-2' : 'justify-center'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<Label
|
||||
htmlFor={currentField.key}
|
||||
className={`${keyboardOpen ? 'text-2xl' : 'text-3xl'} font-bold`}
|
||||
>
|
||||
{currentField.label}
|
||||
{currentField.required ? ' *' : ''}
|
||||
</Label>
|
||||
<div className="mt-4 grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||
<Input
|
||||
id={currentField.key}
|
||||
value={newClient[currentField.key] || ''}
|
||||
onChange={(event) =>
|
||||
setNewClient({ ...newClient, [currentField.key]: event.target.value })
|
||||
}
|
||||
inputMode={currentField.inputMode}
|
||||
enterKeyHint={clientFieldIndex === clientFields.length - 1 ? 'done' : 'next'}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
nextClientField()
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
keyboardOpen ? 'h-16 text-2xl' : 'h-20 text-3xl'
|
||||
} min-w-0`}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
className={`${keyboardOpen ? 'h-16 px-5' : 'h-20 px-6'} text-xl`}
|
||||
onClick={nextClientField}
|
||||
disabled={loading}
|
||||
>
|
||||
{clientFieldIndex === clientFields.length - 1 ? 'Creer' : 'Suivant'}
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-4 text-lg text-muted-foreground ${
|
||||
keyboardOpen ? 'hidden sm:block' : ''
|
||||
}`}
|
||||
>
|
||||
Balayez vers la droite ou appuyez sur Suivant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid shrink-0 grid-cols-2 gap-3 ${
|
||||
keyboardOpen ? 'pb-[max(env(safe-area-inset-bottom),0.25rem)]' : ''
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
|
||||
disabled={clientFieldIndex === 0}
|
||||
onClick={() => setClientFieldIndex((index) => Math.max(0, index - 1))}
|
||||
>
|
||||
<ArrowLeft className="h-6 w-6" />
|
||||
Retour
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`${keyboardOpen ? 'h-12 text-base' : 'h-16 text-xl'}`}
|
||||
onClick={nextClientField}
|
||||
disabled={loading}
|
||||
>
|
||||
Swipe / Next
|
||||
<ArrowRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'service' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">Quel service ?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-md bg-muted p-4 text-xl">
|
||||
{selectedClient?.prenom} {selectedClient?.nom}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Button
|
||||
className="h-32 flex-col text-2xl"
|
||||
variant={serviceType === 'COMBO' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setServiceType('COMBO')
|
||||
setStep('details')
|
||||
}}
|
||||
>
|
||||
<Package className="h-9 w-9" />
|
||||
Monture + verres
|
||||
</Button>
|
||||
<Button
|
||||
className="h-32 flex-col text-2xl"
|
||||
variant={serviceType === 'REPAIR' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setServiceType('REPAIR')
|
||||
setStep('details')
|
||||
}}
|
||||
>
|
||||
<Wrench className="h-9 w-9" />
|
||||
Reparation
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" className="h-14 text-lg" onClick={() => setStep('client')}>
|
||||
Retour
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'details' && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">
|
||||
{serviceType === 'REPAIR' ? 'Details reparation' : 'Produits'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{serviceType === 'COMBO' ? (
|
||||
<>
|
||||
<Input
|
||||
value={productSearch}
|
||||
onChange={(event) => setProductSearch(event.target.value)}
|
||||
placeholder="Chercher monture, verre, reference..."
|
||||
className="h-16 text-xl"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{filteredProducts.map((product) => (
|
||||
<Button
|
||||
key={product.id}
|
||||
variant="outline"
|
||||
className="h-auto justify-between gap-3 p-4 text-left"
|
||||
onClick={() => addProduct(product)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{product.designation}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{product.reference} - Stock {product.stock}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold">{currency(product.prixVenteTTC)}</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
value={comboDetails}
|
||||
onChange={(event) => setComboDetails(event.target.value)}
|
||||
placeholder="Notes simples: correction, couleur, mesures..."
|
||||
className="min-h-24 text-lg"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={repairType}
|
||||
onChange={(event) => setRepairType(event.target.value)}
|
||||
placeholder="Type: branche, vis, soudure..."
|
||||
className="h-16 text-xl"
|
||||
/>
|
||||
<Textarea
|
||||
value={repairDescription}
|
||||
onChange={(event) => setRepairDescription(event.target.value)}
|
||||
placeholder="Description claire de la reparation"
|
||||
className="min-h-40 text-xl"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={repairPrice || ''}
|
||||
onChange={(event) => setRepairPrice(Number(event.target.value))}
|
||||
placeholder="Prix reparation"
|
||||
className="h-16 text-xl"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Resume</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedProducts.map((item) => (
|
||||
<div key={item.produitId} className="rounded-md border p-3">
|
||||
<div className="font-semibold">{item.label}</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span>Qté {item.quantite}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeProduct(item.produitId)}>
|
||||
Retirer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Label className="text-base">Frais supplementaires</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={additionalCharges || ''}
|
||||
onChange={(event) => setAdditionalCharges(Number(event.target.value))}
|
||||
className="mt-2 h-14 text-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">Paiement</Label>
|
||||
<Select value={paymentMode} onValueChange={(value: PaymentMode) => setPaymentMode(value)}>
|
||||
<SelectTrigger className="mt-2 h-14 w-full text-lg">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(paymentLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<div className="text-muted-foreground">Total</div>
|
||||
<div className="text-4xl font-bold">{currency(total)}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="outline" className="h-14 text-lg" onClick={() => setStep('service')}>
|
||||
Retour
|
||||
</Button>
|
||||
<Button className="h-14 text-lg" onClick={submitSale} disabled={loading}>
|
||||
<Check className="h-5 w-5" />
|
||||
Valider
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'final' && sale && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||
<FileText className="h-8 w-8" />
|
||||
Recu pret
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div id="seller-wizard-receipt" className="receipt rounded-md border bg-white p-5 text-black">
|
||||
<h1>OptiqueStock</h1>
|
||||
<p className="muted">Recu {sale.numero}</p>
|
||||
<p className="muted">{new Date(sale.date).toLocaleString('fr-FR')}</p>
|
||||
{sale.client && (
|
||||
<p>
|
||||
Client: {sale.client.prenom} {sale.client.nom} - {sale.client.telephone}
|
||||
</p>
|
||||
)}
|
||||
<hr />
|
||||
{sale.lignes.map((line, index) => (
|
||||
<div key={index} className="row">
|
||||
<span>
|
||||
{line.quantite} x {line.produit.designation}
|
||||
</span>
|
||||
<strong>{currency(line.montantTTC)}</strong>
|
||||
</div>
|
||||
))}
|
||||
{sale.notes && <p className="muted">{sale.notes}</p>}
|
||||
<div className="row">
|
||||
<span>HT</span>
|
||||
<span>{currency(sale.montantHT)}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span>TVA</span>
|
||||
<span>{currency(sale.montantTVA)}</span>
|
||||
</div>
|
||||
<div className="row total">
|
||||
<span>Total TTC</span>
|
||||
<span>{currency(sale.montantTTC)}</span>
|
||||
</div>
|
||||
<p className="muted">Paiement: {paymentLabels[sale.paiements[0]?.mode || 'ESPECES']}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Button className="h-16 text-xl" onClick={printReceipt}>
|
||||
<Printer className="h-6 w-6" />
|
||||
Imprimer maintenant
|
||||
</Button>
|
||||
<Button variant="outline" className="h-16 text-xl" onClick={resetWizard}>
|
||||
Nouvelle vente
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
22
src/components/theme-toggle.tsx
Normal file
22
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
title={isDark ? 'Mode clair' : 'Mode sombre'}
|
||||
aria-label={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user