Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
224 lines
9.4 KiB
TypeScript
224 lines
9.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Button } from '@/components/ui/button'
|
|
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
|
|
import { toast } from 'sonner'
|
|
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
|
|
import { format } from 'date-fns'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
const TIERS = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE'] as const
|
|
|
|
const tierColors: Record<string, string> = {
|
|
BASIC: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
|
PRO: 'bg-brand-accent/10 text-brand-accent',
|
|
BUSINESS: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
ENTERPRISE: 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
|
}
|
|
|
|
function TierDropdown({ userId, tier, isOpen, onToggle, onClose, onChange }: {
|
|
userId: string
|
|
tier: string
|
|
isOpen: boolean
|
|
onToggle: () => void
|
|
onClose: () => void
|
|
onChange: (tier: string) => void
|
|
}) {
|
|
const btnRef = useRef<HTMLButtonElement>(null)
|
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
|
|
const updatePos = useCallback(() => {
|
|
if (btnRef.current) {
|
|
const r = btnRef.current.getBoundingClientRect()
|
|
setPos({ top: r.bottom + 4, left: r.left })
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
updatePos()
|
|
window.addEventListener('scroll', updatePos, true)
|
|
window.addEventListener('resize', updatePos)
|
|
}
|
|
return () => {
|
|
window.removeEventListener('scroll', updatePos, true)
|
|
window.removeEventListener('resize', updatePos)
|
|
}
|
|
}, [isOpen, updatePos])
|
|
|
|
return (
|
|
<td className="p-4 align-middle">
|
|
<button
|
|
ref={btnRef}
|
|
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors cursor-pointer hover:opacity-80 ${tierColors[tier] || tierColors.BASIC}`}
|
|
>
|
|
{tier === 'ENTERPRISE' && <Crown size={10} />}
|
|
{tier}
|
|
<ChevronDown size={10} />
|
|
</button>
|
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
|
<>
|
|
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
|
<div
|
|
className="fixed z-[9999] bg-white dark:bg-zinc-900 border border-border rounded-xl shadow-xl py-1 min-w-[140px]"
|
|
style={{ top: pos.top, left: pos.left }}
|
|
>
|
|
{TIERS.map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={(e) => { e.stopPropagation(); onChange(t) }}
|
|
className={`w-full text-left px-4 py-2 text-xs font-medium transition-colors hover:bg-muted flex items-center gap-2 ${tier === t ? 'text-brand-accent font-bold' : 'text-foreground'}`}
|
|
>
|
|
{tier === t && '✓'}
|
|
<span className={`inline-flex items-center gap-1 ${tier === t ? '' : 'ml-4'}`}>
|
|
{t === 'ENTERPRISE' && <Crown size={10} />}
|
|
{t}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
</td>
|
|
)
|
|
}
|
|
|
|
export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|
const { t } = useLanguage()
|
|
const [users, setUsers] = useState(initialUsers)
|
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
|
const [resettingId, setResettingId] = useState<string | null>(null)
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm(t('admin.users.confirmDelete'))) return
|
|
try {
|
|
await deleteUser(id)
|
|
toast.success(t('admin.users.deleteSuccess'))
|
|
setUsers(prev => prev.filter(u => u.id !== id))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.deleteFailed'))
|
|
}
|
|
}
|
|
|
|
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
|
|
const label = user.email || user.name || user.id
|
|
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
|
|
setResettingId(user.id)
|
|
try {
|
|
const result = await resetUserQuotas(user.id)
|
|
toast.success(
|
|
t('admin.users.resetQuotasSuccess', {
|
|
period: result.period,
|
|
count: result.featuresReset.length,
|
|
}),
|
|
)
|
|
} catch (e) {
|
|
toast.error(
|
|
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
|
|
)
|
|
} finally {
|
|
setResettingId(null)
|
|
}
|
|
}
|
|
|
|
const handleRoleToggle = async (user: any) => {
|
|
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
|
try {
|
|
await updateUserRole(user.id, newRole)
|
|
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
|
|
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, role: newRole } : u))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.roleUpdateFailed'))
|
|
}
|
|
}
|
|
|
|
const handleTierChange = async (userId: string, tier: string) => {
|
|
setOpenDropdown(null)
|
|
try {
|
|
await updateUserSubscription(userId, tier)
|
|
toast.success(t('admin.users.tierUpdateSuccess', { tier }))
|
|
setUsers(prev => prev.map(u => u.id === userId ? { ...u, subscription: { tier, status: 'ACTIVE' } } : u))
|
|
} catch (e) {
|
|
toast.error(t('admin.users.tierUpdateFailed'))
|
|
}
|
|
}
|
|
|
|
const getUserTier = (user: any) => user.subscription?.tier || 'BASIC'
|
|
|
|
return (
|
|
<div className="w-full overflow-auto">
|
|
<table className="w-full caption-bottom text-sm text-left">
|
|
<thead className="[&_tr]:border-b">
|
|
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.subscription')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
|
|
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="[&_tr:last-child]:border-0">
|
|
{users.map((user) => {
|
|
const tier = getUserTier(user)
|
|
return (
|
|
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
|
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
|
|
<td className="p-4 align-middle">{user.email}</td>
|
|
<td className="p-4 align-middle">
|
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
|
|
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
|
|
</span>
|
|
</td>
|
|
<TierDropdown
|
|
userId={user.id}
|
|
tier={tier}
|
|
isOpen={openDropdown === user.id}
|
|
onToggle={() => setOpenDropdown(openDropdown === user.id ? null : user.id)}
|
|
onClose={() => setOpenDropdown(null)}
|
|
onChange={(t) => handleTierChange(user.id, t)}
|
|
/>
|
|
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
|
<td className="p-4 align-middle text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleResetQuotas(user)}
|
|
disabled={resettingId === user.id}
|
|
title={t('admin.users.resetQuotas')}
|
|
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
|
|
>
|
|
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRoleToggle(user)}
|
|
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
|
|
>
|
|
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(user.id)}
|
|
className="text-red-600 hover:text-red-900"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|