monacousa-portal/src/routes/(app)/board/members/+page.svelte

375 lines
12 KiB
Svelte

<script lang="ts">
import {
Users,
Search,
Filter,
Mail,
Phone,
Calendar,
MapPin,
Flag,
ChevronDown,
Eye,
UserCircle,
CreditCard,
CheckCircle2,
Clock,
AlertCircle,
XCircle
} from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import CountryFlag from '$lib/components/ui/CountryFlag.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { data } = $props();
const { members, statuses, stats, filters } = data;
let searchQuery = $state(filters.search);
let statusFilter = $state(filters.status);
let roleFilter = $state(filters.role);
let showFilters = $state(false);
// Debounce search
let searchTimeout: ReturnType<typeof setTimeout>;
function handleSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
updateFilters({ search: value });
}, 300);
}
function updateFilters(newFilters: Record<string, string>) {
const params = new URLSearchParams($page.url.searchParams);
for (const [key, value] of Object.entries(newFilters)) {
if (value && value !== 'all') {
params.set(key, value);
} else {
params.delete(key);
}
}
goto(`?${params.toString()}`, { replaceState: true });
}
// Format date
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
// Get status info
function getStatusInfo(status: string | null) {
switch (status) {
case 'active':
return { icon: CheckCircle2, color: 'text-green-600', bg: 'bg-green-100' };
case 'pending':
return { icon: Clock, color: 'text-yellow-600', bg: 'bg-yellow-100' };
case 'inactive':
return { icon: XCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
case 'expired':
return { icon: AlertCircle, color: 'text-red-600', bg: 'bg-red-100' };
default:
return { icon: UserCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
}
}
// Get dues status info
function getDuesInfo(status: string | null) {
switch (status) {
case 'current':
return { color: 'text-green-600', bg: 'bg-green-100', label: 'Current' };
case 'due_soon':
return { color: 'text-yellow-600', bg: 'bg-yellow-100', label: 'Due Soon' };
case 'overdue':
return { color: 'text-red-600', bg: 'bg-red-100', label: 'Overdue' };
case 'never_paid':
default:
return { color: 'text-slate-500', bg: 'bg-slate-100', label: 'Never Paid' };
}
}
// Get role badge
function getRoleBadge(role: string) {
switch (role) {
case 'admin':
return { color: 'text-purple-700', bg: 'bg-purple-100', label: 'Admin' };
case 'board':
return { color: 'text-blue-700', bg: 'bg-blue-100', label: 'Board' };
default:
return { color: 'text-slate-600', bg: 'bg-slate-100', label: 'Member' };
}
}
</script>
<svelte:head>
<title>Members Directory | Monaco USA</title>
</svelte:head>
<div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Members Directory</h1>
<p class="text-slate-500">View and manage association members</p>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="glass-card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-slate-100 p-2">
<Users class="h-5 w-5 text-slate-600" />
</div>
<div>
<p class="text-2xl font-bold text-slate-900">{stats.total}</p>
<p class="text-xs text-slate-500">Total Members</p>
</div>
</div>
</div>
<div class="glass-card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2">
<CheckCircle2 class="h-5 w-5 text-green-600" />
</div>
<div>
<p class="text-2xl font-bold text-slate-900">{stats.active}</p>
<p class="text-xs text-slate-500">Active</p>
</div>
</div>
</div>
<div class="glass-card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-yellow-100 p-2">
<Clock class="h-5 w-5 text-yellow-600" />
</div>
<div>
<p class="text-2xl font-bold text-slate-900">{stats.pending}</p>
<p class="text-xs text-slate-500">Pending</p>
</div>
</div>
</div>
<div class="glass-card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-slate-100 p-2">
<XCircle class="h-5 w-5 text-slate-500" />
</div>
<div>
<p class="text-2xl font-bold text-slate-900">{stats.inactive}</p>
<p class="text-xs text-slate-500">Inactive</p>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="glass-card p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
placeholder="Search by name, email, or member ID..."
value={searchQuery}
oninput={(e) => {
searchQuery = e.currentTarget.value;
handleSearch(e.currentTarget.value);
}}
class="h-10 pl-9"
/>
</div>
<button
onclick={() => (showFilters = !showFilters)}
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
<Filter class="h-4 w-4" />
Filters
<ChevronDown class="h-4 w-4 transition-transform {showFilters ? 'rotate-180' : ''}" />
</button>
</div>
{#if showFilters}
<div class="mt-4 flex flex-wrap gap-4 border-t border-slate-200 pt-4">
<div>
<label class="mb-1 block text-xs font-medium text-slate-500">Status</label>
<select
bind:value={statusFilter}
onchange={() => updateFilters({ status: statusFilter })}
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
>
<option value="all">All Statuses</option>
{#each statuses as status}
<option value={status.name}>{status.display_name}</option>
{/each}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-slate-500">Role</label>
<select
bind:value={roleFilter}
onchange={() => updateFilters({ role: roleFilter })}
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
>
<option value="all">All Roles</option>
<option value="member">Member</option>
<option value="board">Board</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
{/if}
</div>
<!-- Members Table -->
<div class="glass-card overflow-hidden">
{#if members.length === 0}
<div class="flex flex-col items-center justify-center p-12 text-center">
<Users class="mb-4 h-16 w-16 text-slate-300" />
<h3 class="text-lg font-medium text-slate-900">No members found</h3>
<p class="mt-1 text-slate-500">
{filters.search || filters.status !== 'all' || filters.role !== 'all'
? 'Try adjusting your search or filters.'
: 'Members will appear here when added.'}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
Member
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
Contact
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
Dues
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
Member Since
</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each members as member}
{@const statusInfo = getStatusInfo(member.status_name)}
{@const duesInfo = getDuesInfo(member.dues_status)}
{@const roleBadge = getRoleBadge(member.role)}
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
{#if member.avatar_url}
<img
src={member.avatar_url}
alt=""
class="h-10 w-10 rounded-full object-cover"
/>
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100 text-monaco-700"
>
{member.first_name?.[0]}{member.last_name?.[0]}
</div>
{/if}
<div>
<div class="flex items-center gap-2">
<p class="font-medium text-slate-900">
{member.first_name} {member.last_name}
</p>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {roleBadge.bg} {roleBadge.color}"
>
{roleBadge.label}
</span>
{#if member.nationality && member.nationality.length > 0}
<div class="flex items-center gap-0.5">
{#each member.nationality as code}
<CountryFlag {code} size="xs" />
{/each}
</div>
{/if}
</div>
<p class="text-xs text-slate-500">{member.member_id}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-sm text-slate-600">
<Mail class="h-3.5 w-3.5" />
<a href="mailto:{member.email}" class="hover:text-monaco-600">
{member.email}
</a>
</div>
{#if member.phone}
<div class="flex items-center gap-1.5 text-sm text-slate-500">
<Phone class="h-3.5 w-3.5" />
{member.phone}
</div>
{/if}
</div>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {statusInfo.bg} {statusInfo.color}"
>
<svelte:component this={statusInfo.icon} class="h-3.5 w-3.5" />
{member.status_display_name || member.status_name || 'Unknown'}
</span>
</td>
<td class="px-6 py-4">
<div class="space-y-1">
<span
class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {duesInfo.bg} {duesInfo.color}"
>
{duesInfo.label}
</span>
{#if member.current_due_date}
<p class="text-xs text-slate-500">
Due: {formatDate(member.current_due_date)}
</p>
{/if}
</div>
</td>
<td class="px-6 py-4 text-sm text-slate-500">
{formatDate(member.member_since)}
</td>
<td class="px-6 py-4 text-right">
<div class="flex justify-end gap-2">
<a
href="/board/members/{member.id}"
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
title="View Details"
>
<Eye class="h-4 w-4" />
</a>
<a
href="/board/dues?member={member.id}"
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-monaco-600"
title="Manage Dues"
>
<CreditCard class="h-4 w-4" />
</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>