469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
'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>
|
|
)
|
|
}
|