feat: implement pool members management functionality in admin panel
This commit is contained in:
parent
430c72d4cd
commit
7d9399df4b
4
ToDo.txt
4
ToDo.txt
@ -23,7 +23,7 @@ Last updated: 2026-01-20
|
|||||||
(Compromised User / Pool )
|
(Compromised User / Pool )
|
||||||
|
|
||||||
• [x] Compromised User Fix (SAT)
|
• [x] Compromised User Fix (SAT)
|
||||||
• [ ] Pools Complete Setup check and refactor -- Implementing Logging Layout from Alex -- Talk with him (SAT)
|
• [x] Pools Complete Setup check and refactor -- Implementing Logging Layout from Alex -- Talk with him (SAT)
|
||||||
• [x] Adjust and add Functionality for Download Acc Data and Delete Acc (SAT)
|
• [x] Adjust and add Functionality for Download Acc Data and Delete Acc (SAT)
|
||||||
• [ ] News Management (own pages for news) + Adjust the Dashboard to Display Latest news
|
• [ ] News Management (own pages for news) + Adjust the Dashboard to Display Latest news
|
||||||
• [ ] Unified Modal Design
|
• [ ] Unified Modal Design
|
||||||
@ -31,7 +31,7 @@ Last updated: 2026-01-20
|
|||||||
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
|
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
|
||||||
• [ ] Remove irrelevant statuses in userverify filter
|
• [ ] Remove irrelevant statuses in userverify filter
|
||||||
• [ ] User Status 1 Feld das wir nicht benutzen
|
• [ ] User Status 1 Feld das wir nicht benutzen
|
||||||
• [ ]
|
• [ ] Pool mulit user actions (select 5 -> add to pool)
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
QUICK SHARED / CROSSOVER ITEMS
|
QUICK SHARED / CROSSOVER ITEMS
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export function useAdminPools() {
|
|||||||
price: Number(item.price ?? 0),
|
price: Number(item.price ?? 0),
|
||||||
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
is_active: Boolean(item.is_active),
|
is_active: Boolean(item.is_active),
|
||||||
membersCount: 0,
|
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||||
}));
|
}));
|
||||||
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
|
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
|
||||||
@ -103,7 +103,7 @@ export function useAdminPools() {
|
|||||||
price: Number(item.price ?? 0),
|
price: Number(item.price ?? 0),
|
||||||
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
is_active: Boolean(item.is_active),
|
is_active: Boolean(item.is_active),
|
||||||
membersCount: 0,
|
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||||
})));
|
})));
|
||||||
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
|
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIc
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||||
|
import { AdminAPI } from '../../../utils/api'
|
||||||
|
|
||||||
type PoolUser = {
|
type PoolUser = {
|
||||||
id: string
|
id: string
|
||||||
@ -20,6 +21,7 @@ function PoolManagePageInner() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
|
const token = useAuthStore(s => s.accessToken)
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
!!user &&
|
!!user &&
|
||||||
(
|
(
|
||||||
@ -54,6 +56,8 @@ function PoolManagePageInner() {
|
|||||||
|
|
||||||
// Members (no dummy data)
|
// Members (no dummy data)
|
||||||
const [users, setUsers] = React.useState<PoolUser[]>([])
|
const [users, setUsers] = React.useState<PoolUser[]>([])
|
||||||
|
const [membersLoading, setMembersLoading] = React.useState(false)
|
||||||
|
const [membersError, setMembersError] = React.useState<string>('')
|
||||||
|
|
||||||
// Stats (no dummy data)
|
// Stats (no dummy data)
|
||||||
const [totalAmount, setTotalAmount] = React.useState<number>(0)
|
const [totalAmount, setTotalAmount] = React.useState<number>(0)
|
||||||
@ -67,11 +71,43 @@ function PoolManagePageInner() {
|
|||||||
const [error, setError] = React.useState<string>('')
|
const [error, setError] = React.useState<string>('')
|
||||||
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
|
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
|
||||||
const [hasSearched, setHasSearched] = React.useState(false)
|
const [hasSearched, setHasSearched] = React.useState(false)
|
||||||
|
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
||||||
|
const [savingMembers, setSavingMembers] = React.useState(false)
|
||||||
|
|
||||||
|
async function fetchMembers() {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
setMembersError('')
|
||||||
|
setMembersLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await AdminAPI.getPoolMembers(token, poolId)
|
||||||
|
const rows = Array.isArray(resp?.members) ? resp.members : []
|
||||||
|
const mapped: PoolUser[] = rows.map((row: any) => {
|
||||||
|
const name = row.company_name
|
||||||
|
? String(row.company_name)
|
||||||
|
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name: name || String(row.email || '').trim() || 'Unnamed user',
|
||||||
|
email: String(row.email || '').trim(),
|
||||||
|
contributed: 0,
|
||||||
|
joinedAt: row.joined_at || new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setUsers(mapped)
|
||||||
|
} catch (e: any) {
|
||||||
|
setMembersError(e?.message || 'Failed to load pool members.')
|
||||||
|
} finally {
|
||||||
|
setMembersLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void fetchMembers()
|
||||||
|
}, [token, poolId])
|
||||||
|
|
||||||
// Early return AFTER all hooks are declared to keep consistent order
|
// Early return AFTER all hooks are declared to keep consistent order
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
|
|
||||||
// Remove dummy candidate source; keep search scaffolding returning empty
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
setError('')
|
setError('')
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
@ -80,23 +116,99 @@ function PoolManagePageInner() {
|
|||||||
setCandidates([])
|
setCandidates([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!token) {
|
||||||
|
setError('Authentication required.')
|
||||||
setHasSearched(true)
|
setHasSearched(true)
|
||||||
setLoading(true)
|
setCandidates([])
|
||||||
setTimeout(() => {
|
return
|
||||||
setCandidates([]) // no local dummy results
|
|
||||||
setLoading(false)
|
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUserFromModal(u: { id: string; name: string; email: string }) {
|
setHasSearched(true)
|
||||||
// Append user to pool; contribution stays zero; joinedAt is now.
|
setLoading(true)
|
||||||
setUsers(prev => [{ id: u.id, name: u.name, email: u.email, contributed: 0, joinedAt: new Date().toISOString() }, ...prev])
|
try {
|
||||||
|
const resp = await AdminAPI.getUserList(token)
|
||||||
|
const list = Array.isArray(resp?.users) ? resp.users : []
|
||||||
|
|
||||||
|
const existingIds = new Set(users.map(u => String(u.id)))
|
||||||
|
|
||||||
|
const mapped = list
|
||||||
|
.filter((u: any) => u && u.role !== 'admin' && u.role !== 'super_admin')
|
||||||
|
.map((u: any) => {
|
||||||
|
const name = u.company_name
|
||||||
|
? String(u.company_name)
|
||||||
|
: [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
return {
|
||||||
|
id: String(u.id),
|
||||||
|
name: name || String(u.email || '').trim() || 'Unnamed user',
|
||||||
|
email: String(u.email || '').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(u => !existingIds.has(u.id))
|
||||||
|
.filter(u => {
|
||||||
|
const hay = `${u.name} ${u.email}`.toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
})
|
||||||
|
|
||||||
|
setCandidates(mapped)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to search users.')
|
||||||
|
setCandidates([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserFromModal(u: { id: string; name: string; email: string }) {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
setSavingMembers(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await AdminAPI.addPoolMembers(token, poolId, [u.id])
|
||||||
|
await fetchMembers()
|
||||||
setSearchOpen(false)
|
setSearchOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setCandidates([])
|
setCandidates([])
|
||||||
setHasSearched(false)
|
setHasSearched(false)
|
||||||
setError('')
|
setSelectedCandidates(new Set())
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to add user.')
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setSavingMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCandidate(id: string) {
|
||||||
|
setSelectedCandidates(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSelectedUsers() {
|
||||||
|
if (selectedCandidates.size === 0) return
|
||||||
|
const selectedList = candidates.filter(c => selectedCandidates.has(c.id))
|
||||||
|
if (selectedList.length === 0) return
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
setSavingMembers(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const userIds = selectedList.map(u => u.id)
|
||||||
|
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
||||||
|
await fetchMembers()
|
||||||
|
setSearchOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to add users.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setSavingMembers(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -215,7 +327,17 @@ function PoolManagePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{users.length === 0 && (
|
{membersLoading && (
|
||||||
|
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||||
|
Loading members...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{membersError && !membersLoading && (
|
||||||
|
<div className="col-span-full text-center text-red-600 py-6">
|
||||||
|
{membersError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{users.length === 0 && !membersLoading && !membersError && (
|
||||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||||
No users in this pool yet.
|
No users in this pool yet.
|
||||||
</div>
|
</div>
|
||||||
@ -308,6 +430,13 @@ function PoolManagePageInner() {
|
|||||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
||||||
{candidates.map(u => (
|
{candidates.map(u => (
|
||||||
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
|
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
|
||||||
|
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
|
||||||
|
checked={selectedCandidates.has(u.id)}
|
||||||
|
onChange={() => toggleCandidate(u.id)}
|
||||||
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UsersIcon className="h-4 w-4 text-blue-900" />
|
<UsersIcon className="h-4 w-4 text-blue-900" />
|
||||||
@ -315,6 +444,7 @@ function PoolManagePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
|
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => addUserFromModal(u)}
|
onClick={() => addUserFromModal(u)}
|
||||||
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
||||||
@ -335,13 +465,25 @@ function PoolManagePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-end bg-gray-50">
|
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-between bg-gray-50">
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{selectedCandidates.size > 0 ? `${selectedCandidates.size} selected` : 'No users selected'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(false)}
|
onClick={() => setSearchOpen(false)}
|
||||||
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
|
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addSelectedUsers}
|
||||||
|
disabled={selectedCandidates.size === 0 || savingMembers}
|
||||||
|
className="text-sm rounded-md px-4 py-2 font-medium bg-blue-900 text-white hover:bg-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{savingMembers ? 'Adding…' : 'Add Selected'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export const API_ENDPOINTS = {
|
|||||||
ADMIN_UPDATE_USER_VERIFICATION: '/api/admin/update-verification/:id',
|
ADMIN_UPDATE_USER_VERIFICATION: '/api/admin/update-verification/:id',
|
||||||
ADMIN_UPDATE_USER_PROFILE: '/api/admin/update-user-profile/:id',
|
ADMIN_UPDATE_USER_PROFILE: '/api/admin/update-user-profile/:id',
|
||||||
ADMIN_UPDATE_USER_STATUS: '/api/admin/update-user-status/:id',
|
ADMIN_UPDATE_USER_STATUS: '/api/admin/update-user-status/:id',
|
||||||
|
// Pools (admin)
|
||||||
|
ADMIN_POOL_MEMBERS: '/api/admin/pools/:id/members',
|
||||||
// Coffee products (admin)
|
// Coffee products (admin)
|
||||||
ADMIN_COFFEE_LIST: '/api/admin/coffee',
|
ADMIN_COFFEE_LIST: '/api/admin/coffee',
|
||||||
ADMIN_COFFEE_CREATE: '/api/admin/coffee',
|
ADMIN_COFFEE_CREATE: '/api/admin/coffee',
|
||||||
@ -343,6 +345,26 @@ export class AdminAPI {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getPoolMembers(token: string, poolId: string | number) {
|
||||||
|
const endpoint = API_ENDPOINTS.ADMIN_POOL_MEMBERS.replace(':id', String(poolId))
|
||||||
|
const response = await ApiClient.get(endpoint, token)
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Failed to fetch pool members' }))
|
||||||
|
throw new Error(error.message || 'Failed to fetch pool members')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addPoolMembers(token: string, poolId: string | number, userIds: Array<string | number>) {
|
||||||
|
const endpoint = API_ENDPOINTS.ADMIN_POOL_MEMBERS.replace(':id', String(poolId))
|
||||||
|
const response = await ApiClient.post(endpoint, { userIds }, token)
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Failed to add pool members' }))
|
||||||
|
throw new Error(error.message || 'Failed to add pool members')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
static async updateUserStatus(token: string, userId: string, status: string) {
|
static async updateUserStatus(token: string, userId: string, status: string) {
|
||||||
const endpoint = API_ENDPOINTS.ADMIN_UPDATE_USER_STATUS.replace(':id', userId)
|
const endpoint = API_ENDPOINTS.ADMIN_UPDATE_USER_STATUS.replace(':id', userId)
|
||||||
const response = await ApiClient.patch(endpoint, { status }, token)
|
const response = await ApiClient.patch(endpoint, { status }, token)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user