338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { useTeam, TeamMember } from '../hooks/useTeam'
|
|
import InviteModal from '../components/team/InviteModal'
|
|
import EditMemberModal from '../components/team/EditMemberModal'
|
|
import PermissionsMatrix from '../components/team/PermissionsMatrix'
|
|
import { useEmployee } from '@/hooks/useEmployee' // your existing auth hook
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
|
|
function initials(m: TeamMember) {
|
|
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
|
}
|
|
|
|
function timeAgo(dateStr: string | null) {
|
|
if (!dateStr) return 'Never'
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const mins = Math.floor(diff / 60000)
|
|
if (mins < 2) return 'Just now'
|
|
if (mins < 60) return `${mins}m ago`
|
|
const hrs = Math.floor(mins / 60)
|
|
if (hrs < 24) return `${hrs}h ago`
|
|
const days = Math.floor(hrs / 24)
|
|
if (days < 30) return `${days}d ago`
|
|
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
const AVATAR_BG: string[] = [
|
|
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
|
|
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
|
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
|
|
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
|
|
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
|
|
]
|
|
|
|
function avatarColor(id: string) {
|
|
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
|
|
return AVATAR_BG[hash % AVATAR_BG.length]
|
|
}
|
|
|
|
function RoleBadge({ role }: { role: string }) {
|
|
const styles: Record<string, string> = {
|
|
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
|
|
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
|
|
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
|
|
}
|
|
return (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
|
|
{role.charAt(0) + role.slice(1).toLowerCase()}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function StatusBadge({ member }: { member: TeamMember }) {
|
|
if (member.invitationStatus === 'pending') {
|
|
return (
|
|
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
|
|
Pending invite
|
|
</span>
|
|
)
|
|
}
|
|
if (!member.isActive) {
|
|
return (
|
|
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
|
|
Deactivated
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
|
|
Active
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── Main page ────────────────────────────────────────────────
|
|
|
|
export default function TeamPage() {
|
|
const { employee: currentEmployee } = useEmployee()
|
|
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
|
|
|
const [search, setSearch] = useState('')
|
|
const [roleFilter, setRoleFilter] = useState<string>('')
|
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
const [inviteOpen, setInviteOpen] = useState(false)
|
|
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
|
|
const [toast, setToast] = useState<string | null>(null)
|
|
|
|
const isOwner = currentEmployee?.role === 'OWNER'
|
|
|
|
function showToast(msg: string) {
|
|
setToast(msg)
|
|
setTimeout(() => setToast(null), 3000)
|
|
}
|
|
|
|
const filtered = useMemo(() => {
|
|
return members.filter((m) => {
|
|
const q = search.toLowerCase()
|
|
const matchQ = !q ||
|
|
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
|
|
m.email.toLowerCase().includes(q)
|
|
const matchRole = !roleFilter || m.role === roleFilter
|
|
const matchStatus =
|
|
!statusFilter ||
|
|
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
|
|
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
|
|
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
|
|
return matchQ && matchRole && matchStatus
|
|
})
|
|
}, [members, search, roleFilter, statusFilter])
|
|
|
|
// ── Handlers with toasts ───────────────────────────────────
|
|
|
|
const handleInvite: typeof invite = async (payload) => {
|
|
await invite(payload)
|
|
showToast(`Invite sent to ${payload.email}`)
|
|
}
|
|
|
|
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
|
|
await updateRole(id, role)
|
|
showToast('Role updated')
|
|
}
|
|
|
|
const handleDeactivate = async (id: string) => {
|
|
await deactivate(id)
|
|
showToast('Member deactivated')
|
|
}
|
|
|
|
const handleReactivate = async (id: string) => {
|
|
await reactivate(id)
|
|
showToast('Member reactivated')
|
|
}
|
|
|
|
const handleRemove = async (id: string) => {
|
|
await remove(id)
|
|
showToast('Member removed')
|
|
}
|
|
|
|
// ── Render ─────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
|
|
|
|
{/* Page header */}
|
|
<div className="flex items-start justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
|
Manage your employees and their access levels
|
|
</p>
|
|
</div>
|
|
{isOwner && (
|
|
<button
|
|
onClick={() => setInviteOpen(true)}
|
|
className="flex items-center gap-1.5 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"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
|
|
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
Invite member
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stat cards */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
|
{[
|
|
{ label: 'Total members', value: stats.total },
|
|
{ label: 'Active', value: stats.active },
|
|
{ label: 'Pending invite', value: stats.pending },
|
|
{ label: 'Deactivated', value: stats.inactive },
|
|
].map((s) => (
|
|
<div
|
|
key={s.label}
|
|
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
|
|
>
|
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
|
|
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
<div className="relative flex-1 min-w-48">
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
|
|
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
|
|
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name or email…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 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>
|
|
<select
|
|
value={roleFilter}
|
|
onChange={(e) => setRoleFilter(e.target.value)}
|
|
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
|
>
|
|
<option value="">All roles</option>
|
|
<option value="OWNER">Owner</option>
|
|
<option value="MANAGER">Manager</option>
|
|
<option value="AGENT">Agent</option>
|
|
</select>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
|
>
|
|
<option value="">All statuses</option>
|
|
<option value="active">Active</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="inactive">Deactivated</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
|
|
{loading ? (
|
|
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
|
|
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
|
|
<span className="text-sm">Loading team…</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="py-12 text-center text-sm text-red-500">{error}</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="py-16 text-center">
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
|
|
</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
|
|
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
|
|
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
|
|
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
|
|
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((member, i) => (
|
|
<tr
|
|
key={member.id}
|
|
className={[
|
|
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
|
|
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
|
|
].join(' ')}
|
|
>
|
|
{/* Member */}
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
|
|
{initials(member)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
|
{member.firstName} {member.lastName}
|
|
{member.id === currentEmployee?.id && (
|
|
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Role */}
|
|
<td className="px-4 py-3">
|
|
<RoleBadge role={member.role} />
|
|
</td>
|
|
|
|
{/* Status */}
|
|
<td className="px-4 py-3">
|
|
<StatusBadge member={member} />
|
|
</td>
|
|
|
|
{/* Last active */}
|
|
<td className="px-4 py-3 hidden sm:table-cell">
|
|
<span className="text-xs text-zinc-400 dark:text-zinc-500">
|
|
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Actions */}
|
|
<td className="px-4 py-3 text-right">
|
|
{isOwner && member.role !== 'OWNER' && (
|
|
<button
|
|
onClick={() => setEditTarget(member)}
|
|
className="text-xs px-3 py-1.5 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"
|
|
>
|
|
Manage
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Permissions matrix */}
|
|
<PermissionsMatrix />
|
|
|
|
{/* Modals */}
|
|
<InviteModal
|
|
open={inviteOpen}
|
|
onClose={() => setInviteOpen(false)}
|
|
onInvite={handleInvite}
|
|
/>
|
|
<EditMemberModal
|
|
member={editTarget}
|
|
open={!!editTarget}
|
|
onClose={() => setEditTarget(null)}
|
|
onUpdateRole={handleUpdateRole}
|
|
onDeactivate={handleDeactivate}
|
|
onReactivate={handleReactivate}
|
|
onRemove={handleRemove}
|
|
/>
|
|
|
|
{/* Toast */}
|
|
{toast && (
|
|
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
|
|
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
|
|
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
{toast}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|