375 lines
12 KiB
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>
|