feat: add matrix detail with dummy data
This commit is contained in:
parent
9f5da2c43d
commit
ac4b742214
307
src/app/admin/matrix-management/detail/page.tsx
Normal file
307
src/app/admin/matrix-management/detail/page.tsx
Normal file
@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PageLayout from '../../../components/PageLayout'
|
||||
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type MatrixUser = {
|
||||
id: string | number
|
||||
name: string
|
||||
email: string
|
||||
type: UserType
|
||||
level: number // 0 for top-node, then 1..N
|
||||
}
|
||||
|
||||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||||
|
||||
// Dummy users to pick from in the modal
|
||||
const DUMMY_POOL: Array<Omit<MatrixUser, 'level'>> = [
|
||||
{ id: 101, name: 'Alice Johnson', email: 'alice@example.com', type: 'personal' },
|
||||
{ id: 102, name: 'Beta GmbH', email: 'office@beta-gmbh.de', type: 'company' },
|
||||
{ id: 103, name: 'Carlos Diaz', email: 'carlos@sample.io', type: 'personal' },
|
||||
{ id: 104, name: 'Delta Solutions AG', email: 'contact@delta.ag', type: 'company' },
|
||||
{ id: 105, name: 'Emily Nguyen', email: 'emily.ng@ex.com', type: 'personal' },
|
||||
{ id: 106, name: 'Foxtrot LLC', email: 'hello@foxtrot.llc', type: 'company' },
|
||||
{ id: 107, name: 'Grace Blake', email: 'grace@ex.com', type: 'personal' },
|
||||
{ id: 108, name: 'Hestia Corp', email: 'hq@hestia.io', type: 'company' },
|
||||
{ id: 109, name: 'Ivan Petrov', email: 'ivan@ex.com', type: 'personal' },
|
||||
{ id: 110, name: 'Juno Partners', email: 'team@juno.partners', type: 'company' },
|
||||
]
|
||||
|
||||
export default function MatrixDetailPage() {
|
||||
const sp = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const matrixId = sp.get('id') || 'm-1'
|
||||
const matrixName = sp.get('name') || 'Unnamed Matrix'
|
||||
const topNodeEmail = sp.get('top') || 'top@example.com'
|
||||
|
||||
// Build initial dummy matrix users
|
||||
const [users, setUsers] = useState<MatrixUser[]>(() => {
|
||||
// Level 0 = top node from URL
|
||||
const initial: MatrixUser[] = [
|
||||
{ id: 'top', name: 'Top Node', email: topNodeEmail, type: 'personal', level: 0 },
|
||||
]
|
||||
// Fill some demo users across levels
|
||||
const seed: Omit<MatrixUser, 'level'>[] = DUMMY_POOL.slice(0, 12)
|
||||
let remaining = [...seed]
|
||||
let level = 1
|
||||
while (remaining.length > 0 && level <= 3) {
|
||||
const cap = LEVEL_CAP(level)
|
||||
const take = Math.min(remaining.length, Math.max(1, Math.min(cap, 6))) // keep demo compact
|
||||
initial.push(...remaining.splice(0, take).map(u => ({ ...u, level })))
|
||||
level++
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
// Modal state
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
||||
|
||||
// Available candidates = pool minus already added ids
|
||||
const availableUsers = useMemo(() => {
|
||||
const picked = new Set(users.map(u => String(u.id)))
|
||||
return DUMMY_POOL.filter(u => !picked.has(String(u.id)))
|
||||
}, [users])
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return availableUsers.filter(u => {
|
||||
if (typeFilter !== 'all' && u.type !== typeFilter) return false
|
||||
if (!q) return true
|
||||
return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||||
})
|
||||
}, [availableUsers, query, typeFilter])
|
||||
|
||||
// Counts per level and next available level logic
|
||||
const byLevel = useMemo(() => {
|
||||
const map = new Map<number, MatrixUser[]>()
|
||||
users.forEach(u => {
|
||||
const arr = map.get(u.level) || []
|
||||
arr.push(u)
|
||||
map.set(u.level, arr)
|
||||
})
|
||||
return map
|
||||
}, [users])
|
||||
|
||||
const nextAvailableLevel = () => {
|
||||
// Start from level 1 upwards; find first with space; else go to next new level
|
||||
let lvl = 1
|
||||
while (true) {
|
||||
const current = byLevel.get(lvl)?.length || 0
|
||||
if (current < LEVEL_CAP(lvl)) return lvl
|
||||
lvl += 1
|
||||
if (lvl > 8) return lvl // safety ceiling in demo
|
||||
}
|
||||
}
|
||||
|
||||
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
|
||||
setUsers(prev => [...prev, { ...u, level: nextAvailableLevel() }])
|
||||
}
|
||||
|
||||
// Simple chip for user
|
||||
const UserChip = ({ u }: { u: MatrixUser }) => (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gray-50 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||
{u.type === 'company' ? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" /> : <UserIcon className="h-4 w-4 text-blue-600" />}
|
||||
<span className="font-medium truncate max-w-[140px]">{u.name}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Compact grid for a level
|
||||
const LevelSection = ({ level }: { level: number }) => {
|
||||
const cap = level === 0 ? 1 : LEVEL_CAP(level)
|
||||
const list = byLevel.get(level) || []
|
||||
const pct = Math.min(100, Math.round((list.length / cap) * 100))
|
||||
const title =
|
||||
level === 0 ? 'Level 0 (Top node)' : `Level ${level} — ${list.length} / ${cap}`
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-4 sm:px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
|
||||
<span className="text-[11px] font-semibold text-indigo-700">{pct}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="px-4 sm:px-5 py-3">
|
||||
<div className="h-2 w-full rounded-full bg-gray-100 ring-1 ring-gray-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-500 transition-[width] duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div className="px-4 sm:px-5 pb-4">
|
||||
{list.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">No users in this level yet.</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{list.slice(0, 30).map(u => <UserChip key={`${level}-${u.id}`} u={u} />)}
|
||||
{list.length > 30 && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] text-gray-700">
|
||||
+{list.length - 30} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen w-full px-4 sm:px-6 py-8 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header / Overview */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => router.push('/admin/matrix-management')}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to matrices
|
||||
</button>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">{matrixName}</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Top node: <span className="font-medium text-gray-900">{topNodeEmail}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 shadow-sm"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Add users to matrix
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Levels display */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<LevelSection level={0} />
|
||||
<LevelSection level={1} />
|
||||
<LevelSection level={2} />
|
||||
<LevelSection level={3} />
|
||||
{/* Add more levels visually if needed */}
|
||||
</div>
|
||||
|
||||
{/* Full users list by level */}
|
||||
<div className="mt-8 rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<h2 className="text-base font-semibold text-gray-900">All users in this matrix</h2>
|
||||
<p className="text-xs text-gray-600">Grouped by levels (power of five structure).</p>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{[...byLevel.keys()].sort((a, b) => a - b).map(lvl => {
|
||||
const list = byLevel.get(lvl) || []
|
||||
return (
|
||||
<div key={lvl} className="px-5 py-4">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-800">
|
||||
{lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} • {list.length} user(s)
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{list.map(u => (
|
||||
<div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-200 p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
u.type === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{u.type === 'company' ? 'Company' : 'Personal'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">{u.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Users Modal */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl rounded-xl bg-white shadow-2xl ring-1 ring-black/10 overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900">Add users to “{matrixName}”</h3>
|
||||
<button onClick={() => setOpen(false)} className="text-sm text-gray-600 hover:text-gray-800">Close</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 grid grid-cols-1 md:grid-cols-4 gap-3 border-b border-gray-100">
|
||||
<div className="md:col-span-2 relative">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-500 absolute left-2.5 top-2.5" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value) }}
|
||||
placeholder="Search name or email…"
|
||||
className="w-full rounded-md border border-gray-300 pl-8 pr-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value as any)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
<div className="text-sm text-gray-600 self-center">
|
||||
Candidates: <span className="font-semibold text-gray-900">{filteredCandidates.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-auto">
|
||||
{filteredCandidates.length === 0 ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-500">No users match your filters.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{filteredCandidates.map(u => (
|
||||
<li key={u.id} className="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
||||
<div className="text-xs text-gray-600">{u.email}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
u.type === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{u.type === 'company' ? 'Company' : 'Personal'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => addToMatrix(u)}
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-end">
|
||||
<button onClick={() => setOpen(false)} className="text-sm text-gray-700 hover:text-gray-900">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -316,7 +316,14 @@ export default function MatrixManagementPage() {
|
||||
</button>
|
||||
<button
|
||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
|
||||
onClick={() => alert(`Placeholder: open matrix details for ${m.name}`)}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams({
|
||||
id: String(m.id),
|
||||
name: m.name,
|
||||
top: m.topNodeEmail,
|
||||
})
|
||||
router.push(`/admin/matrix-management/detail?${params.toString()}`)
|
||||
}}
|
||||
>
|
||||
View details →
|
||||
</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user