269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
'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">
|
|
|
|
{/* Member header */}
|
|
<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 overlay */}
|
|
{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>
|
|
)}
|
|
|
|
{/* Role selector (hidden for pending invites — role locked until accepted) */}
|
|
{!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 */}
|
|
{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>
|
|
)}
|
|
|
|
{/* Footer actions */}
|
|
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
|
{/* Danger zone */}
|
|
<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>
|
|
)
|
|
}
|