feat: add personal matrix
This commit is contained in:
parent
c1e250bab1
commit
6e2298eca9
@ -28,7 +28,6 @@ import {
|
|||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import useAuthStore from '../../store/authStore';
|
import useAuthStore from '../../store/authStore';
|
||||||
import { Avatar } from '../avatar';
|
import { Avatar } from '../avatar';
|
||||||
import LanguageSwitcher from '../LanguageSwitcher';
|
|
||||||
|
|
||||||
// Replace current shopItems definition with detailed version (adds icon & description)
|
// Replace current shopItems definition with detailed version (adds icon & description)
|
||||||
const shopItems = [
|
const shopItems = [
|
||||||
@ -297,22 +296,31 @@ export default function Header() {
|
|||||||
Affiliate-Links
|
Affiliate-Links
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Memberships */}
|
{/* Remove 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>
|
|
||||||
|
|
||||||
{/* Referral Management - match others (no highlight) */}
|
{/* Referral Management - match others (no highlight) */}
|
||||||
{userPresent && hasReferralPerm && (
|
{userPresent && hasReferralPerm && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { console.log('🧭 Header: navigate to /referral-management'); router.push('/referral-management') }}
|
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"
|
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
|
Referral Management
|
||||||
</button>
|
</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 */}
|
{/* About us */}
|
||||||
@ -383,8 +391,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 aria-hidden="true" className="w-20 h-8 rounded-md bg-gray-200 dark:bg-gray-700/70 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Language & theme remain after auth slot */}
|
{/* Replace LanguageSwitcher with English-only dropdown + note */}
|
||||||
<LanguageSwitcher variant={isDark ? 'dark' : 'light'} />
|
<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
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
@ -577,9 +597,16 @@ export default function Header() {
|
|||||||
{isDark ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
{isDark ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
||||||
{isDark ? 'Light Mode' : 'Dark Mode'}
|
{isDark ? 'Light Mode' : 'Dark Mode'}
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-4 px-1">
|
{/* Replace LanguageSwitcher with English-only dropdown + note for mobile */}
|
||||||
<LanguageSwitcher variant="dark" />
|
<Disclosure as="div" className="mt-4 px-1">
|
||||||
</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>
|
||||||
{/* Navigation / Shop after that */}
|
{/* Navigation / Shop after that */}
|
||||||
<div className="space-y-2 py-6">
|
<div className="space-y-2 py-6">
|
||||||
@ -610,21 +637,29 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Affiliate-Links
|
Affiliate-Links
|
||||||
</button>
|
</button>
|
||||||
{/* Memberships */}
|
{/* Remove Memberships */}
|
||||||
<button
|
{/* Referral Management + new items */}
|
||||||
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) */}
|
|
||||||
{hasReferralPerm && (
|
{hasReferralPerm && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { console.log('🧭 Header Mobile: navigate to /referral-management'); router.push('/referral-management'); setMobileMenuOpen(false); }}
|
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"
|
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
|
Referral Management
|
||||||
</button>
|
</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 */}
|
{/* About us */}
|
||||||
<button
|
<button
|
||||||
@ -663,18 +698,25 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Affiliate-Links
|
Affiliate-Links
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Remove Memberships */}
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { router.push('/about-us'); setMobileMenuOpen(false); }}
|
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"
|
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
|
About us
|
||||||
</button>
|
</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">
|
<div className="px-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
|
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
|
||||||
|
|||||||
@ -24,23 +24,38 @@ export type PersonalOverview = {
|
|||||||
level2Plus: Level2PlusItem[]
|
level2Plus: Level2PlusItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePersonalMatrixOverview() {
|
export type UserMatrix = {
|
||||||
const [data, setData] = useState<PersonalOverview | null>(null)
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Abort any inflight when re-running
|
|
||||||
abortRef.current?.abort()
|
abortRef.current?.abort()
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
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)
|
setLoading(true)
|
||||||
// Do not clear error if we already have valid data; only clear when starting fresh
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
authFetch(url, {
|
authFetch(url, {
|
||||||
@ -51,14 +66,153 @@ export function usePersonalMatrixOverview() {
|
|||||||
})
|
})
|
||||||
.then(async r => {
|
.then(async r => {
|
||||||
const ct = r.headers.get('content-type') || ''
|
const ct = r.headers.get('content-type') || ''
|
||||||
|
console.log('[useMyMatrices] Status:', r.status, 'CT:', ct)
|
||||||
if (!r.ok || !ct.includes('application/json')) {
|
if (!r.ok || !ct.includes('application/json')) {
|
||||||
const txt = await r.text().catch(() => '')
|
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)}`)
|
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
|
||||||
}
|
}
|
||||||
const json = await r.json()
|
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 d = json?.data || json
|
||||||
const mapped: PersonalOverview = {
|
const mapped: PersonalOverview = {
|
||||||
matrixInstanceId: d.matrixInstanceId,
|
matrixInstanceId: d.matrixInstanceId ?? matrixId,
|
||||||
totalUsersUnderMe: Number(d.totalUsersUnderMe ?? 0),
|
totalUsersUnderMe: Number(d.totalUsersUnderMe ?? 0),
|
||||||
levelsFilled: Number(d.levelsFilled ?? 0),
|
levelsFilled: Number(d.levelsFilled ?? 0),
|
||||||
immediateChildrenCount: Number(d.immediateChildrenCount ?? 0),
|
immediateChildrenCount: Number(d.immediateChildrenCount ?? 0),
|
||||||
@ -75,37 +229,39 @@ export function usePersonalMatrixOverview() {
|
|||||||
nameMasked: String(x.name ?? '')
|
nameMasked: String(x.name ?? '')
|
||||||
})) : []
|
})) : []
|
||||||
}
|
}
|
||||||
|
console.log('[usePersonalMatrixOverview] Mapped:', mapped)
|
||||||
setData(mapped)
|
setData(mapped)
|
||||||
})
|
})
|
||||||
.catch((e: any) => {
|
.catch((e: any) => {
|
||||||
// Ignore aborts (during navigation/unmount or re-run)
|
if (controller.signal.aborted || e?.name === 'AbortError') {
|
||||||
if (controller.signal.aborted) {
|
console.log('[usePersonalMatrixOverview] Aborted')
|
||||||
// Do not set error; just exit quietly
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Some environments throw DOMException name 'AbortError'
|
|
||||||
if (e?.name === 'AbortError') {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
console.error('[usePersonalMatrixOverview] Error:', e)
|
||||||
setError(e?.message || 'Failed to load overview')
|
setError(e?.message || 'Failed to load overview')
|
||||||
setData(null)
|
setData(null)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
console.log('[usePersonalMatrixOverview] Done')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup: abort on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[usePersonalMatrixOverview] Cleanup abort')
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [matrixId])
|
||||||
|
|
||||||
const meta = useMemo(() => ({
|
const meta = useMemo(() => {
|
||||||
|
const m = {
|
||||||
countL1: data?.level1.length ?? 0,
|
countL1: data?.level1.length ?? 0,
|
||||||
countL2Plus: data?.level2Plus.length ?? 0
|
countL2Plus: data?.level2Plus.length ?? 0
|
||||||
}), [data])
|
}
|
||||||
|
console.log('[usePersonalMatrixOverview] Meta computed:', m)
|
||||||
|
return m
|
||||||
|
}, [data])
|
||||||
|
|
||||||
return { data, loading, error, meta }
|
return { data, loading, error, meta }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,44 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import { UsersIcon, CalendarDaysIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
|
import { UsersIcon, CalendarDaysIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
|
||||||
import { usePersonalMatrixOverview } from './hooks/getStats'
|
import { usePersonalMatrixOverview, useMyMatrices } from './hooks/getStats'
|
||||||
|
|
||||||
export default function PersonalMatrixPage() {
|
export default function PersonalMatrixPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
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(() => {
|
useEffect(() => {
|
||||||
if (user === null) router.replace('/login')
|
if (user === null) router.replace('/login')
|
||||||
}, [user, router])
|
}, [user, router])
|
||||||
if (user === null) return null
|
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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||||
@ -24,39 +47,149 @@ export default function PersonalMatrixPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<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"
|
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
Back
|
{selectedId == null ? 'Back' : 'Back to matrices'}
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">My Matrix Overview</h1>
|
<h1 className="text-3xl font-extrabold text-blue-900">
|
||||||
<p className="text-base text-blue-700">Personal subtree, privacy-preserving beyond level 1.</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected matrix overview */}
|
||||||
|
{selectedId != null && (
|
||||||
|
<>
|
||||||
|
{/* Stats cards */}
|
||||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
<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="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-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 className="text-xl font-semibold text-blue-900">
|
||||||
|
{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<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-xs text-gray-500 mb-1">Immediate children</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{data?.immediateChildrenCount ?? (loading ? '…' : 0)}</div>
|
<div className="text-xl font-semibold text-blue-900">
|
||||||
|
{data?.immediateChildrenCount ?? (loading ? '…' : 0)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<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-xs text-gray-500 mb-1">Levels filled</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{data?.levelsFilled ?? (loading ? '…' : 0)}</div>
|
<div className="text-xl font-semibold text-blue-900">
|
||||||
|
{data?.levelsFilled ?? (loading ? '…' : 0)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Level 1 */}
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
<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">
|
<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>
|
<h2 className="text-xl font-semibold text-blue-900">Level 1 (Direct Children)</h2>
|
||||||
@ -85,6 +218,7 @@ export default function PersonalMatrixPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Level 2+ */}
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
<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">
|
<div className="px-8 py-6 border-b border-gray-100">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Level 2+</h2>
|
<h2 className="text-xl font-semibold text-blue-900">Level 2+</h2>
|
||||||
@ -108,6 +242,7 @@ export default function PersonalMatrixPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<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-500 mb-1">Overview meta</div>
|
||||||
<div className="text-xs text-gray-700">
|
<div className="text-xs text-gray-700">
|
||||||
@ -115,6 +250,8 @@ export default function PersonalMatrixPage() {
|
|||||||
• Level1: {meta.countL1} • Level2+: {meta.countL2Plus}
|
• Level1: {meta.countL1} • Level2+: {meta.countL2Plus}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user