Add tablet seller wizard and access controls
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user