fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
@@ -0,0 +1,261 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
</span>
)}
</div>
</div>
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
</p>
<div className="flex gap-2">
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
)}
</button>
</div>
</div>
)}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex gap-2 mr-auto">
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,199 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
]
export default function InviteModal({ open, onClose, onInvite }: Props) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They&apos;ll receive an email invitation to join your dashboard
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
</span>
)}
</div>
</button>
))}
</div>
</div>
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending…' : 'Send invite →'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
'use client'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
</div>
)
}
export default function PermissionsMatrix() {
return (
<div>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={feature.label}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
</div>
</div>
))}
</div>
</div>
)
}