feat: implement pool members management functionality in admin panel

This commit is contained in:
seaznCode 2026-01-23 21:54:11 +01:00
parent 430c72d4cd
commit 7d9399df4b
4 changed files with 204 additions and 40 deletions

View File

@ -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

View File

@ -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);

View File

@ -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>

View File

@ -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)