feat: add matrix detail with dummy data

This commit is contained in:
DeathKaioken 2025-10-22 20:59:47 +02:00
parent 9f5da2c43d
commit ac4b742214
2 changed files with 315 additions and 1 deletions

View 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>
)
}

View File

@ -316,7 +316,14 @@ export default function MatrixManagementPage() {
</button> </button>
<button <button
className="text-sm font-medium text-indigo-600 hover:text-indigo-500" 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 View details
</button> </button>