Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev
This commit is contained in:
commit
23691ec50c
@ -28,7 +28,6 @@ import {
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import useAuthStore from '../../store/authStore';
|
||||
import { Avatar } from '../avatar';
|
||||
import LanguageSwitcher from '../LanguageSwitcher';
|
||||
|
||||
// Replace current shopItems definition with detailed version (adds icon & description)
|
||||
const shopItems = [
|
||||
@ -297,22 +296,31 @@ export default function Header() {
|
||||
Affiliate-Links
|
||||
</button>
|
||||
|
||||
{/* Memberships */}
|
||||
<button
|
||||
onClick={() => router.push('/memberships')}
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
Memberships
|
||||
</button>
|
||||
|
||||
{/* Remove Memberships */}
|
||||
{/* Referral Management - match others (no highlight) */}
|
||||
{userPresent && hasReferralPerm && (
|
||||
<button
|
||||
onClick={() => { console.log('🧭 Header: navigate to /referral-management'); router.push('/referral-management') }}
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
Referral Management
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => { console.log('🧭 Header: navigate to /referral-management'); router.push('/referral-management') }}
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
Referral Management
|
||||
</button>
|
||||
{/* New: Personal Matrix */}
|
||||
<button
|
||||
onClick={() => router.push('/personal-matrix')}
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
Personal Matrix
|
||||
</button>
|
||||
{/* New: Coffee Abonnements */}
|
||||
<button
|
||||
onClick={() => router.push('/coffee-abonnements')}
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
Coffee Abonnements
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* About us */}
|
||||
@ -390,8 +398,20 @@ export default function Header() {
|
||||
<div aria-hidden="true" className="w-20 h-8 rounded-md bg-gray-200 dark:bg-gray-700/70 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{/* Language & theme remain after auth slot */}
|
||||
<LanguageSwitcher variant={isDark ? 'dark' : 'light'} />
|
||||
{/* Replace LanguageSwitcher with English-only dropdown + note */}
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="p-2 rounded-md border border-gray-300 dark:border-white/10 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm font-semibold">
|
||||
English
|
||||
<ChevronDownIcon aria-hidden="true" className="ml-1 inline h-4 w-4 text-gray-500" />
|
||||
</PopoverButton>
|
||||
<PopoverPanel
|
||||
transition
|
||||
className="absolute right-0 top-full mt-2 w-72 rounded-md bg-white dark:bg-gray-900 ring-1 ring-black/10 dark:ring-white/10 shadow-lg p-3 text-sm text-gray-800 dark:text-gray-200 data-closed:-translate-y-1 data-closed:opacity-0 data-enter:duration-200 data-leave:duration-150"
|
||||
>
|
||||
We are currently working on implementing other languages.
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
@ -590,9 +610,16 @@ export default function Header() {
|
||||
{isDark ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
||||
{isDark ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
<div className="mt-4 px-1">
|
||||
<LanguageSwitcher variant="dark" />
|
||||
</div>
|
||||
{/* Replace LanguageSwitcher with English-only dropdown + note for mobile */}
|
||||
<Disclosure as="div" className="mt-4 px-1">
|
||||
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
|
||||
Language: English
|
||||
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel className="mt-2 space-y-1 px-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
We are currently working on implementing other languages.
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
{/* Navigation / Shop after that */}
|
||||
<div className="space-y-2 py-6">
|
||||
@ -623,21 +650,29 @@ export default function Header() {
|
||||
>
|
||||
Affiliate-Links
|
||||
</button>
|
||||
{/* Memberships */}
|
||||
<button
|
||||
onClick={() => { router.push('/memberships'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Memberships
|
||||
</button>
|
||||
{/* Referral Management - match others (no highlight) */}
|
||||
{/* Remove Memberships */}
|
||||
{/* Referral Management + new items */}
|
||||
{hasReferralPerm && (
|
||||
<button
|
||||
onClick={() => { console.log('🧭 Header Mobile: navigate to /referral-management'); router.push('/referral-management'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Referral Management
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => { console.log('🧭 Header Mobile: navigate to /referral-management'); router.push('/referral-management'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Referral Management
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { router.push('/personal-matrix'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Personal Matrix
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Coffee Abonnements
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* About us */}
|
||||
<button
|
||||
@ -676,18 +711,25 @@ export default function Header() {
|
||||
>
|
||||
Affiliate-Links
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { router.push('/memberships'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Memberships
|
||||
</button>
|
||||
{/* Remove Memberships */}
|
||||
<button
|
||||
onClick={() => { router.push('/about-us'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
About us
|
||||
</button>
|
||||
{/* Language (English-only) in logged-out mobile */}
|
||||
<div className="px-3">
|
||||
<Disclosure as="div">
|
||||
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
|
||||
Language: English
|
||||
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel className="mt-2 space-y-1 px-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
We are currently working on implementing other languages.
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<button
|
||||
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
|
||||
|
||||
@ -24,23 +24,38 @@ export type PersonalOverview = {
|
||||
level2Plus: Level2PlusItem[]
|
||||
}
|
||||
|
||||
export function usePersonalMatrixOverview() {
|
||||
const [data, setData] = useState<PersonalOverview | null>(null)
|
||||
export type UserMatrix = {
|
||||
id: string | number
|
||||
name: string
|
||||
membersCount?: number
|
||||
createdAt?: string
|
||||
description?: string
|
||||
highestFullLevel?: number
|
||||
percentFull?: number
|
||||
filledSlots?: number
|
||||
totalSlots?: number
|
||||
totalUsers?: number
|
||||
// new quick-summary fields from list endpoint
|
||||
totalUsersUnderMe?: number
|
||||
matrixFillPercent?: number
|
||||
}
|
||||
|
||||
export function useMyMatrices() {
|
||||
const [matrices, setMatrices] = useState<UserMatrix[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Abort any inflight when re-running
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/matrix/me/overview`
|
||||
const url = `${base}/api/matrix/me/list`
|
||||
console.log('[useMyMatrices] Fetching:', url)
|
||||
|
||||
setLoading(true)
|
||||
// Do not clear error if we already have valid data; only clear when starting fresh
|
||||
setError(null)
|
||||
|
||||
authFetch(url, {
|
||||
@ -51,14 +66,153 @@ export function usePersonalMatrixOverview() {
|
||||
})
|
||||
.then(async r => {
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
console.log('[useMyMatrices] Status:', r.status, 'CT:', ct)
|
||||
if (!r.ok || !ct.includes('application/json')) {
|
||||
const txt = await r.text().catch(() => '')
|
||||
console.warn('[useMyMatrices] Non-JSON or error body:', txt.slice(0, 200))
|
||||
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
|
||||
}
|
||||
const json = await r.json()
|
||||
console.log('[useMyMatrices] Raw JSON:', json)
|
||||
const arr = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
|
||||
if (!Array.isArray(arr)) {
|
||||
console.warn('[useMyMatrices] Expected array, got:', typeof arr)
|
||||
}
|
||||
const mapped: UserMatrix[] = arr.map((m: any, idx: number) => {
|
||||
const id = m.id ?? m.matrixInstanceId ?? m._id
|
||||
const name = String(m.name ?? m.title ?? `Matrix ${id ?? ''}`).trim()
|
||||
|
||||
// summary metrics directly from the list route
|
||||
const totalUsersUnderMe =
|
||||
m.totalUsersUnderMe != null ? Number(m.totalUsersUnderMe) : undefined
|
||||
const highestFullLevel =
|
||||
m.highestFullLevel != null ? Number(m.highestFullLevel) :
|
||||
m.maxFilledLevel != null ? Number(m.maxFilledLevel) :
|
||||
m.highestLevelFull != null ? Number(m.highestLevelFull) :
|
||||
undefined
|
||||
const matrixFillPercentRaw =
|
||||
m.matrixFillPercent != null ? Number(m.matrixFillPercent) : undefined
|
||||
const matrixFillPercent =
|
||||
matrixFillPercentRaw != null ? Math.max(0, Math.min(100, matrixFillPercentRaw)) : undefined
|
||||
|
||||
// legacy/optional fields (kept for backward compatibility)
|
||||
const membersCount =
|
||||
m.membersCount != null ? Number(m.membersCount) :
|
||||
m.memberCount != null ? Number(m.memberCount) :
|
||||
m.totalUsers != null ? Number(m.totalUsers) :
|
||||
undefined
|
||||
const filledSlots =
|
||||
m.filledSlots != null ? Number(m.filledSlots) :
|
||||
m.occupiedSlots != null ? Number(m.occupiedSlots) :
|
||||
undefined
|
||||
const totalSlots =
|
||||
m.totalSlots != null ? Number(m.totalSlots) :
|
||||
m.capacitySlots != null ? Number(m.capacitySlots) :
|
||||
undefined
|
||||
const percentFromApi =
|
||||
m.percentFull != null ? Number(m.percentFull) :
|
||||
m.fillPercent != null ? Number(m.fillPercent) :
|
||||
undefined
|
||||
const percentComputed =
|
||||
filledSlots != null && totalSlots != null && totalSlots > 0
|
||||
? (filledSlots / totalSlots) * 100
|
||||
: undefined
|
||||
const percent = percentFromApi ?? percentComputed
|
||||
const percentClamped = percent != null ? Math.max(0, Math.min(100, percent)) : undefined
|
||||
const createdAt = m.createdAt ? String(m.createdAt) : (m.created_at ? String(m.created_at) : undefined)
|
||||
const description = m.description != null ? String(m.description) : undefined
|
||||
const totalUsers = m.totalUsers != null ? Number(m.totalUsers) : undefined
|
||||
|
||||
const out = {
|
||||
id,
|
||||
name,
|
||||
membersCount,
|
||||
createdAt,
|
||||
description,
|
||||
highestFullLevel, // from list
|
||||
totalUsersUnderMe, // from list
|
||||
matrixFillPercent, // from list
|
||||
filledSlots,
|
||||
totalSlots,
|
||||
percentFull: percentClamped,
|
||||
totalUsers
|
||||
}
|
||||
|
||||
console.log('[useMyMatrices] Map item', idx, { source: m, mapped: out })
|
||||
return out
|
||||
})
|
||||
console.log('[useMyMatrices] Final mapped list:', mapped)
|
||||
setMatrices(mapped)
|
||||
})
|
||||
.catch((e: any) => {
|
||||
if (controller.signal.aborted || e?.name === 'AbortError') {
|
||||
console.log('[useMyMatrices] Aborted')
|
||||
return
|
||||
}
|
||||
console.error('[useMyMatrices] Error:', e)
|
||||
setError(e?.message || 'Failed to load matrices')
|
||||
setMatrices([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false)
|
||||
console.log('[useMyMatrices] Done')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
console.log('[useMyMatrices] Cleanup abort')
|
||||
controller.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { matrices, loading, error }
|
||||
}
|
||||
|
||||
export function usePersonalMatrixOverview(matrixId?: string | number) {
|
||||
const [data, setData] = useState<PersonalOverview | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (matrixId == null) {
|
||||
console.log('[usePersonalMatrixOverview] No matrixId, skipping fetch')
|
||||
setData(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/matrix/${encodeURIComponent(String(matrixId))}/overview`
|
||||
console.log('[usePersonalMatrixOverview] Fetching:', url)
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
signal: controller.signal as any
|
||||
})
|
||||
.then(async r => {
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
console.log('[usePersonalMatrixOverview] Status:', r.status, 'CT:', ct)
|
||||
if (!r.ok || !ct.includes('application/json')) {
|
||||
const txt = await r.text().catch(() => '')
|
||||
console.warn('[usePersonalMatrixOverview] Non-JSON or error body:', txt.slice(0, 200))
|
||||
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
|
||||
}
|
||||
const json = await r.json()
|
||||
console.log('[usePersonalMatrixOverview] Raw JSON:', json)
|
||||
const d = json?.data || json
|
||||
const mapped: PersonalOverview = {
|
||||
matrixInstanceId: d.matrixInstanceId,
|
||||
matrixInstanceId: d.matrixInstanceId ?? matrixId,
|
||||
totalUsersUnderMe: Number(d.totalUsersUnderMe ?? 0),
|
||||
levelsFilled: Number(d.levelsFilled ?? 0),
|
||||
immediateChildrenCount: Number(d.immediateChildrenCount ?? 0),
|
||||
@ -75,37 +229,39 @@ export function usePersonalMatrixOverview() {
|
||||
nameMasked: String(x.name ?? '')
|
||||
})) : []
|
||||
}
|
||||
console.log('[usePersonalMatrixOverview] Mapped:', mapped)
|
||||
setData(mapped)
|
||||
})
|
||||
.catch((e: any) => {
|
||||
// Ignore aborts (during navigation/unmount or re-run)
|
||||
if (controller.signal.aborted) {
|
||||
// Do not set error; just exit quietly
|
||||
return
|
||||
}
|
||||
// Some environments throw DOMException name 'AbortError'
|
||||
if (e?.name === 'AbortError') {
|
||||
if (controller.signal.aborted || e?.name === 'AbortError') {
|
||||
console.log('[usePersonalMatrixOverview] Aborted')
|
||||
return
|
||||
}
|
||||
console.error('[usePersonalMatrixOverview] Error:', e)
|
||||
setError(e?.message || 'Failed to load overview')
|
||||
setData(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false)
|
||||
console.log('[usePersonalMatrixOverview] Done')
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup: abort on unmount
|
||||
return () => {
|
||||
console.log('[usePersonalMatrixOverview] Cleanup abort')
|
||||
controller.abort()
|
||||
}
|
||||
}, [])
|
||||
}, [matrixId])
|
||||
|
||||
const meta = useMemo(() => ({
|
||||
countL1: data?.level1.length ?? 0,
|
||||
countL2Plus: data?.level2Plus.length ?? 0
|
||||
}), [data])
|
||||
const meta = useMemo(() => {
|
||||
const m = {
|
||||
countL1: data?.level1.length ?? 0,
|
||||
countL2Plus: data?.level2Plus.length ?? 0
|
||||
}
|
||||
console.log('[usePersonalMatrixOverview] Meta computed:', m)
|
||||
return m
|
||||
}, [data])
|
||||
|
||||
return { data, loading, error, meta }
|
||||
}
|
||||
|
||||
@ -1,21 +1,44 @@
|
||||
'use client'
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import { UsersIcon, CalendarDaysIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
|
||||
import { usePersonalMatrixOverview } from './hooks/getStats'
|
||||
import { usePersonalMatrixOverview, useMyMatrices } from './hooks/getStats'
|
||||
|
||||
export default function PersonalMatrixPage() {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const { data, loading, error, meta } = usePersonalMatrixOverview()
|
||||
|
||||
const { matrices, loading: loadingMatrices, error: matricesError } = useMyMatrices()
|
||||
const [selectedId, setSelectedId] = useState<string | number | undefined>(undefined)
|
||||
|
||||
// Auto-select if only one matrix available
|
||||
useEffect(() => {
|
||||
if (selectedId == null && matrices.length === 1) {
|
||||
setSelectedId(matrices[0].id)
|
||||
}
|
||||
}, [matrices, selectedId])
|
||||
|
||||
const { data, loading, error, meta } = usePersonalMatrixOverview(selectedId)
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) router.replace('/login')
|
||||
}, [user, router])
|
||||
if (user === null) return null
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[PersonalMatrixPage] matrices loading:', loadingMatrices, 'error:', matricesError)
|
||||
}, [loadingMatrices, matricesError])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[PersonalMatrixPage] matrices list:', matrices)
|
||||
}, [matrices])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[PersonalMatrixPage] selectedId:', selectedId)
|
||||
}, [selectedId])
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
@ -24,97 +47,211 @@ export default function PersonalMatrixPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
onClick={() => (selectedId == null ? router.push('/') : setSelectedId(undefined))}
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
{selectedId == null ? 'Back' : 'Back to matrices'}
|
||||
</button>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900">My Matrix Overview</h1>
|
||||
<p className="text-base text-blue-700">Personal subtree, privacy-preserving beyond level 1.</p>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900">
|
||||
{selectedId == null ? 'My Matrices' : 'My Matrix Overview'}
|
||||
</h1>
|
||||
<p className="text-base text-blue-700">
|
||||
{selectedId == null
|
||||
? 'Select which matrix you want to inspect.'
|
||||
: 'Personal subtree, privacy-preserving beyond level 1.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
{/* Errors */}
|
||||
{matricesError && (
|
||||
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{matricesError}
|
||||
</div>
|
||||
)}
|
||||
{error && selectedId != null && (
|
||||
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Total users under me</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Immediate children</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{data?.immediateChildrenCount ?? (loading ? '…' : 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Levels filled</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{data?.levelsFilled ?? (loading ? '…' : 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Level 1 (Direct Children)</h2>
|
||||
<p className="text-xs text-blue-700">
|
||||
Up to the first five direct children. {data?.rootSlotsRemaining != null ? `Root slots remaining: ${data.rootSlotsRemaining}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-6">
|
||||
{loading && <div className="text-xs text-gray-500">Loading…</div>}
|
||||
{!loading && (data?.level1?.length ?? 0) === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">No direct children.</div>
|
||||
{/* Matrix card list (overview) */}
|
||||
{selectedId == null && (
|
||||
<div>
|
||||
{loadingMatrices && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="h-32 rounded-xl bg-white shadow animate-pulse" />
|
||||
<div className="h-32 rounded-xl bg-white shadow animate-pulse" />
|
||||
<div className="h-32 rounded-xl bg-white shadow animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data?.level1.map((c) => (
|
||||
<li key={c.userId} className="rounded-lg border border-gray-100 p-4 bg-blue-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-blue-900">{c.name}</div>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
||||
{c.position != null ? `pos ${c.position}` : 'pos —'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-blue-700 break-all">{c.email}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Level 2+</h2>
|
||||
<p className="text-xs text-blue-700">Masked names for deeper descendants.</p>
|
||||
</div>
|
||||
<div className="px-8 py-6">
|
||||
{loading && <div className="text-xs text-gray-500">Loading…</div>}
|
||||
{!loading && (data?.level2Plus?.length ?? 0) === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">No deeper descendants.</div>
|
||||
{!loadingMatrices && matrices.length === 0 && (
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
|
||||
You are not part of any matrix yet.
|
||||
</div>
|
||||
)}
|
||||
{!loadingMatrices && matrices.length > 0 && (
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{matrices.map((m, idx) => {
|
||||
// Prefer new fields from list; fall back to legacy computed percent
|
||||
const percent = m.matrixFillPercent ?? m.percentFull ?? 0
|
||||
const totalUsersDisplay = m.totalUsersUnderMe ?? m.membersCount ?? m.totalUsers
|
||||
console.log('[PersonalMatrixPage] Card', idx, {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
totalUsersUnderMe: m.totalUsersUnderMe,
|
||||
highestFullLevel: m.highestFullLevel,
|
||||
matrixFillPercent: m.matrixFillPercent,
|
||||
legacyPercentFull: m.percentFull,
|
||||
filledSlots: m.filledSlots,
|
||||
totalSlots: m.totalSlots,
|
||||
totalUsers: m.totalUsers,
|
||||
membersCount: m.membersCount
|
||||
})
|
||||
return (
|
||||
<li key={String(m.id)} className="rounded-2xl border border-gray-100 bg-white shadow-lg overflow-hidden">
|
||||
<div className="px-6 py-5">
|
||||
<div className="text-lg font-semibold text-blue-900">{m.name}</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-700">
|
||||
<div>
|
||||
<span className="font-medium text-gray-800">Total members:</span>{' '}
|
||||
{totalUsersDisplay != null ? totalUsersDisplay : 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-800">Highest full level:</span>{' '}
|
||||
{m.highestFullLevel != null ? m.highestFullLevel : 'N/A'}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-gray-800">Matrix fill:</span>
|
||||
<div className="h-2 w-full rounded bg-gray-200">
|
||||
<div
|
||||
className="h-2 rounded bg-blue-600"
|
||||
style={{ width: `${Math.max(0, Math.min(100, percent)).toFixed(0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-600">
|
||||
{Math.max(0, Math.min(100, percent)).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{m.createdAt && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-800">Created:</span>{' '}
|
||||
{new Date(m.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{m.description && (
|
||||
<div className="text-gray-600 line-clamp-2">
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedId(m.id)}
|
||||
className="inline-flex items-center rounded-md bg-blue-800 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-900"
|
||||
>
|
||||
View overview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data?.level2Plus.map((x) => (
|
||||
<li key={x.userId} className="rounded-lg border border-gray-100 p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800">{x.nameMasked}</span>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">L{x.depth}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Overview meta</div>
|
||||
<div className="text-xs text-gray-700">
|
||||
Matrix instance: {data?.matrixInstanceId ?? (loading ? '…' : '—')}{' '}
|
||||
• Level1: {meta.countL1} • Level2+: {meta.countL2Plus}
|
||||
</div>
|
||||
</div>
|
||||
{/* Selected matrix overview */}
|
||||
{selectedId != null && (
|
||||
<>
|
||||
{/* Stats cards */}
|
||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Total users under me</div>
|
||||
<div className="text-xl font-semibold text-blue-900">
|
||||
{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Immediate children</div>
|
||||
<div className="text-xl font-semibold text-blue-900">
|
||||
{data?.immediateChildrenCount ?? (loading ? '…' : 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Levels filled</div>
|
||||
<div className="text-xl font-semibold text-blue-900">
|
||||
{data?.levelsFilled ?? (loading ? '…' : 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level 1 */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Level 1 (Direct Children)</h2>
|
||||
<p className="text-xs text-blue-700">
|
||||
Up to the first five direct children. {data?.rootSlotsRemaining != null ? `Root slots remaining: ${data.rootSlotsRemaining}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-6">
|
||||
{loading && <div className="text-xs text-gray-500">Loading…</div>}
|
||||
{!loading && (data?.level1?.length ?? 0) === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">No direct children.</div>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data?.level1.map((c) => (
|
||||
<li key={c.userId} className="rounded-lg border border-gray-100 p-4 bg-blue-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-blue-900">{c.name}</div>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
||||
{c.position != null ? `pos ${c.position}` : 'pos —'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-blue-700 break-all">{c.email}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level 2+ */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Level 2+</h2>
|
||||
<p className="text-xs text-blue-700">Masked names for deeper descendants.</p>
|
||||
</div>
|
||||
<div className="px-8 py-6">
|
||||
{loading && <div className="text-xs text-gray-500">Loading…</div>}
|
||||
{!loading && (data?.level2Plus?.length ?? 0) === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">No deeper descendants.</div>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data?.level2Plus.map((x) => (
|
||||
<li key={x.userId} className="rounded-lg border border-gray-100 p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800">{x.nameMasked}</span>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">L{x.depth}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Overview meta</div>
|
||||
<div className="text-xs text-gray-700">
|
||||
Matrix instance: {data?.matrixInstanceId ?? (loading ? '…' : '—')}{' '}
|
||||
• Level1: {meta.countL1} • Level2+: {meta.countL2Plus}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user