refactor members page, add pending filter tab

This commit is contained in:
Justin Edmund 2025-12-16 14:45:49 -08:00
parent 5fb2331958
commit c875f3cefb
2 changed files with 156 additions and 389 deletions

View file

@ -10,7 +10,7 @@ export type CrewRole = 'member' | 'vice_captain' | 'captain'
export type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired'
// Member filter options for GET /crew/members
export type MemberFilter = 'active' | 'retired' | 'phantom' | 'all'
export type MemberFilter = 'active' | 'retired' | 'phantom' | 'pending' | 'all'
// Crew from CrewBlueprint
export interface Crew {

View file

@ -21,10 +21,19 @@
import SettingsRow from '$lib/components/ui/SettingsRow.svelte'
import Switch from '$lib/components/ui/switch/Switch.svelte'
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
import MemberRow from '$lib/components/crew/MemberRow.svelte'
import PhantomRow from '$lib/components/crew/PhantomRow.svelte'
import ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte'
import BulkPhantomModal from '$lib/components/crew/BulkPhantomModal.svelte'
import AssignPhantomModal from '$lib/components/crew/AssignPhantomModal.svelte'
import ConfirmClaimModal from '$lib/components/crew/ConfirmClaimModal.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
import type { MemberFilter, CrewMembership, PhantomPlayer, CrewInvitation } from '$lib/types/api/crew'
import type {
MemberFilter,
CrewMembership,
PhantomPlayer,
CrewInvitation
} from '$lib/types/api/crew'
import type { PageData } from './$types'
interface Props {
@ -59,13 +68,9 @@
const activeRosterSize = $derived.by(() => {
// Use active filter data if viewing active, otherwise use dedicated query
const activeMembers =
filter === 'active'
? (membersQuery.data?.members ?? [])
: (activeQuery.data?.members ?? [])
filter === 'active' ? (membersQuery.data?.members ?? []) : (activeQuery.data?.members ?? [])
const activePhantoms =
filter === 'active'
? (membersQuery.data?.phantoms ?? [])
: (activeQuery.data?.phantoms ?? [])
filter === 'active' ? (membersQuery.data?.phantoms ?? []) : (activeQuery.data?.phantoms ?? [])
const activeMemberCount = activeMembers.filter((m) => !m.retired).length
const activePhantomCount = activePhantoms.filter((p) => !p.retired).length
@ -79,13 +84,19 @@
const updateMembershipMutation = useUpdateMembership()
const deletePhantomMutation = useDeletePhantom()
// Filter options
const filterOptions: { value: MemberFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'phantom', label: 'Phantoms' },
{ value: 'retired', label: 'Retired' }
]
// Filter options - Pending only shown to officers
const filterOptions = $derived.by(() => {
const options: { value: MemberFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'phantom', label: 'Phantoms' }
]
if (crewStore.isOfficer) {
options.push({ value: 'pending', label: 'Pending' })
}
options.push({ value: 'retired', label: 'Retired' })
return options
})
// Change filter
function handleFilterChange(newFilter: MemberFilter) {
@ -113,9 +124,6 @@
// Dialog state for scout modal
let scoutModalOpen = $state(false)
// Pending invitations section visibility
let invitationsSectionOpen = $state(true)
// Dialog state for phantom creation
let bulkPhantomDialogOpen = $state(false)
@ -123,28 +131,13 @@
let deletePhantomDialogOpen = $state(false)
let phantomToDelete = $state<PhantomPlayer | null>(null)
// Role display helpers
function getRoleLabel(role: string): string {
switch (role) {
case 'captain':
return 'Captain'
case 'vice_captain':
return 'Vice Captain'
default:
return 'Member'
}
}
// Dialog state for phantom assignment
let assignPhantomDialogOpen = $state(false)
let phantomToAssign = $state<PhantomPlayer | null>(null)
function getRoleClass(role: string): string {
switch (role) {
case 'captain':
return 'captain'
case 'vice_captain':
return 'officer'
default:
return ''
}
}
// Dialog state for confirm claim
let confirmClaimDialogOpen = $state(false)
let phantomToClaim = $state<PhantomPlayer | null>(null)
// Member actions
function openRemoveDialog(member: CrewMembership) {
@ -201,7 +194,7 @@
editingMember = member
editingPhantom = null
// Format date for input
editJoinDate = member.joinedAt ? member.joinedAt.split('T')[0] ?? '' : ''
editJoinDate = member.joinedAt ? (member.joinedAt.split('T')[0] ?? '') : ''
editRetired = member.retired
editDialogOpen = true
}
@ -209,7 +202,7 @@
function openEditPhantomDialog(phantom: PhantomPlayer) {
editingPhantom = phantom
editingMember = null
editJoinDate = phantom.joinedAt ? phantom.joinedAt.split('T')[0] ?? '' : ''
editJoinDate = phantom.joinedAt ? (phantom.joinedAt.split('T')[0] ?? '') : ''
editRetired = phantom.retired
editDialogOpen = true
}
@ -267,6 +260,18 @@
phantomToDelete = null
}
// Phantom assignment
function openAssignPhantomDialog(phantom: PhantomPlayer) {
phantomToAssign = phantom
assignPhantomDialogOpen = true
}
// Confirm claim
function openConfirmClaimDialog(phantom: PhantomPlayer) {
phantomToClaim = phantom
confirmClaimDialogOpen = true
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
@ -285,6 +290,11 @@
const pendingInvitationsCount = $derived(
invitationsQuery.data?.filter((inv) => !isInvitationExpired(inv.expiresAt)).length ?? 0
)
// Get phantoms with pending claims (assigned but not confirmed)
const pendingClaimPhantoms = $derived(
membersQuery.data?.phantoms?.filter((p) => p.claimedBy && !p.claimConfirmed) ?? []
)
</script>
<svelte:head>
@ -293,7 +303,7 @@
<div class="page">
<div class="card">
<CrewHeader title="Members">
<CrewHeader title="Members" backHref="/crew">
{#snippet belowTitle()}
<div class="filter-tabs">
{#each filterOptions as option}
@ -334,50 +344,41 @@
{/snippet}
</CrewHeader>
<!-- Pending Invitations Section (officers only) -->
{#if crewStore.isOfficer && invitationsQuery.data && invitationsQuery.data.length > 0}
<div class="invitations-section">
<button
class="invitations-header"
onclick={() => (invitationsSectionOpen = !invitationsSectionOpen)}
>
<span class="invitations-title">
Pending Invitations
{#if pendingInvitationsCount > 0}
<span class="invitations-count">{pendingInvitationsCount}</span>
{/if}
</span>
<span class="toggle-icon" class:open={invitationsSectionOpen}>▼</span>
</button>
{#if invitationsSectionOpen}
<ul class="invitations-list">
{#each invitationsQuery.data as invitation}
{@const expired = isInvitationExpired(invitation.expiresAt)}
<li class="invitation-item" class:expired>
<div class="invitation-info">
<span class="invited-user">{invitation.user?.username ?? 'Unknown'}</span>
{#if invitation.invitedBy}
<span class="invited-by">
Invited by {invitation.invitedBy.username}
</span>
{/if}
</div>
<div class="invitation-status">
{#if expired}
<span class="status-badge expired">Expired</span>
{:else}
<span class="expires-text">Expires {formatDate(invitation.expiresAt)}</span>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
{#if membersQuery.isLoading}
<!-- Pending Invitations (shown when filter is 'pending') -->
{#if filter === 'pending'}
{#if invitationsQuery.isLoading}
<div class="loading-state">
<p>Loading...</p>
</div>
{:else if invitationsQuery.data && invitationsQuery.data.length > 0}
<ul class="member-list">
{#each invitationsQuery.data as invitation}
{@const expired = isInvitationExpired(invitation.expiresAt)}
<li class="invitation-row" class:expired>
<div class="invitation-info">
<span class="invited-user">{invitation.user?.username ?? 'Unknown'}</span>
{#if invitation.invitedBy}
<span class="invited-by">
Invited by {invitation.invitedBy.username}
</span>
{/if}
</div>
<div class="invitation-status">
{#if expired}
<span class="status-badge expired">Expired</span>
{:else}
<span class="expires-text">Expires {formatDate(invitation.expiresAt)}</span>
{/if}
</div>
</li>
{/each}
</ul>
{:else}
<div class="empty-state">
<p>No pending invitations.</p>
</div>
{/if}
{:else if membersQuery.isLoading}
<div class="loading-state">
<p>Loading...</p>
</div>
@ -390,62 +391,38 @@
{#if membersQuery.data?.members && membersQuery.data.members.length > 0}
<ul class="member-list">
{#each membersQuery.data.members as member}
<li class="member-item" class:retired={member.retired}>
<div class="member-info">
<span class="username">{member.user?.username ?? 'Unknown'}</span>
<span class="role-badge {getRoleClass(member.role)}">
{getRoleLabel(member.role)}
</span>
{#if member.joinedAt}
<span class="joined-date">Joined {formatDate(member.joinedAt)}</span>
{/if}
</div>
{#if crewStore.isOfficer}
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditMemberDialog(member)}
>
Edit
</DropdownMenuBase.Item>
{#if crewStore.canActOnMember(member.role) && !member.retired && member.id !== crewStore.membership?.id}
{#if member.role === 'member' && crewStore.canPromoteTo('vice_captain')}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openPromoteDialog(member)}
>
Promote
</DropdownMenuBase.Item>
{/if}
{#if member.role === 'vice_captain' && crewStore.canDemote('vice_captain')}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openDemoteDialog(member)}
>
Demote
</DropdownMenuBase.Item>
{/if}
<DropdownMenuBase.Item
class="dropdown-menu-item danger"
onclick={() => openRemoveDialog(member)}
>
Remove
</DropdownMenuBase.Item>
{/if}
{/snippet}
</DropdownMenu>
{/if}
</li>
<MemberRow
{member}
onEdit={() => openEditMemberDialog(member)}
onPromote={() => openPromoteDialog(member)}
onDemote={() => openDemoteDialog(member)}
onRemove={() => openRemoveDialog(member)}
/>
{/each}
</ul>
{:else if filter !== 'phantom'}
<p class="empty-state">No members found</p>
{/if}
<!-- Pending Claims Section (officers only) -->
{#if crewStore.isOfficer && pendingClaimPhantoms.length > 0 && (filter === 'all' || filter === 'phantom')}
<div class="section-divider pending-claims">
<span>Pending Claims ({pendingClaimPhantoms.length})</span>
</div>
<ul class="member-list">
{#each pendingClaimPhantoms as phantom}
<PhantomRow
{phantom}
currentUserId={crewStore.membership?.user?.id}
onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)}
/>
{/each}
</ul>
{/if}
<!-- Phantom players -->
{#if membersQuery.data?.phantoms && membersQuery.data.phantoms.length > 0}
{#if filter === 'all' && membersQuery.data.members.length > 0}
@ -455,51 +432,14 @@
{/if}
<ul class="member-list">
{#each membersQuery.data.phantoms as phantom}
<li class="member-item" class:retired={phantom.retired}>
<div class="member-info">
<div class="phantom-details">
<span class="username">{phantom.name}</span>
{#if phantom.granblueId}
<span class="granblue-id">ID: {phantom.granblueId}</span>
{/if}
{#if phantom.joinedAt}
<span class="joined-date">Joined {formatDate(phantom.joinedAt)}</span>
{/if}
</div>
{#if phantom.claimConfirmed && phantom.claimedBy}
<span class="status-badge claimed">
Claimed by {phantom.claimedBy.username}
</span>
{:else if phantom.claimedBy}
<span class="status-badge pending">
Pending: {phantom.claimedBy.username}
</span>
{:else}
<span class="status-badge unclaimed">Unclaimed</span>
{/if}
</div>
{#if crewStore.isOfficer}
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditPhantomDialog(phantom)}
>
Edit
</DropdownMenuBase.Item>
<DropdownMenuBase.Item
class="dropdown-menu-item danger"
onclick={() => openDeletePhantomDialog(phantom)}
>
Delete
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
{/if}
</li>
<PhantomRow
{phantom}
currentUserId={crewStore.membership?.user?.id}
onEdit={() => openEditPhantomDialog(phantom)}
onDelete={() => openDeletePhantomDialog(phantom)}
onAssign={() => openAssignPhantomDialog(phantom)}
onConfirmClaim={() => openConfirmClaimDialog(phantom)}
/>
{/each}
</ul>
{:else if filter === 'phantom'}
@ -543,7 +483,12 @@
<ModalFooter
onCancel={() => (confirmDialogOpen = false)}
primaryAction={{
label: confirmAction === 'remove' ? 'Remove' : confirmAction === 'promote' ? 'Promote' : 'Demote',
label:
confirmAction === 'remove'
? 'Remove'
: confirmAction === 'promote'
? 'Promote'
: 'Demote',
onclick: handleConfirmAction,
destructive: confirmAction === 'remove'
}}
@ -567,10 +512,7 @@
This date is used to determine which events a member was active for when adding
historical GW scores.
</p>
<SettingsRow
title="Retired"
subtitle="This player is no longer a part of the crew"
>
<SettingsRow title="Retired" subtitle="This player is no longer a part of the crew">
{#snippet control()}
<Switch bind:checked={editRetired} name="retired" />
{/snippet}
@ -617,6 +559,16 @@
{#if crewStore.crew?.id}
<ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} />
<BulkPhantomModal bind:open={bulkPhantomDialogOpen} crewId={crewStore.crew.id} />
<AssignPhantomModal
bind:open={assignPhantomDialogOpen}
crewId={crewStore.crew.id}
phantom={phantomToAssign}
/>
<ConfirmClaimModal
bind:open={confirmClaimDialogOpen}
crewId={crewStore.crew.id}
phantom={phantomToClaim}
/>
{/if}
<style lang="scss">
@ -627,7 +579,6 @@
@use '$src/themes/layout' as layout;
.page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
max-width: var(--main-max-width);
}
@ -638,6 +589,10 @@
border-radius: layout.$page-corner;
box-shadow: effects.$page-elevation;
overflow: hidden;
:global(.header-info) {
gap: spacing.$unit-2x;
}
}
.filter-tabs {
@ -700,86 +655,6 @@
padding: spacing.$unit;
}
.member-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
transition: background-color 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
&.retired {
opacity: 0.6;
}
}
.member-info {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.phantom-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
font-size: typography.$font-small;
font-weight: typography.$medium;
}
.granblue-id {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.role-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
font-weight: typography.$medium;
background: rgba(0, 0, 0, 0.04);
&.captain {
background: var(--color-gold-light, #fef3c7);
color: var(--color-gold-dark, #92400e);
}
&.officer {
background: var(--color-blue-light, #dbeafe);
color: var(--color-blue-dark, #1e40af);
}
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
&.unclaimed {
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
}
&.pending {
background: var(--color-yellow-light, #fef9c3);
color: var(--color-yellow-dark, #854d0e);
}
&.claimed {
background: var(--color-green-light, #dcfce7);
color: var(--color-green-dark, #166534);
}
}
.section-divider {
display: flex;
align-items: center;
@ -787,6 +662,15 @@
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
&.pending-claims {
background: var(--color-yellow-light, #fef9c3);
border-bottom-color: var(--color-yellow-dark, #854d0e);
span {
color: var(--color-yellow-dark, #854d0e);
}
}
span {
font-size: typography.$font-small;
font-weight: typography.$medium;
@ -794,35 +678,6 @@
}
}
.action-buttons {
display: flex;
gap: spacing.$unit-half;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: layout.$item-corner-small;
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
font-size: typography.$font-small;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.08);
color: var(--text-primary);
}
&.danger {
color: colors.$error;
&:hover {
background: rgba(colors.$error, 0.1);
}
}
}
// Confirm dialog styles
.confirm-message {
color: var(--text-primary);
@ -852,31 +707,6 @@
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-primary);
.optional {
font-weight: typography.$normal;
color: var(--text-secondary);
}
}
textarea {
padding: spacing.$unit-2x;
border: none;
border-radius: layout.$input-corner;
font-size: typography.$font-regular;
font-family: inherit;
background: var(--input-bound-bg);
color: var(--text-primary);
resize: vertical;
min-height: 60px;
&:hover {
background: var(--input-bound-bg-hover);
}
&::placeholder {
color: var(--text-tertiary);
}
}
}
@ -886,11 +716,6 @@
margin: 0;
}
.joined-date {
font-size: typography.$font-small;
color: var(--text-tertiary);
}
.date-input {
padding: spacing.$unit spacing.$unit-2x;
border: none;
@ -918,75 +743,17 @@
line-height: 1.4;
}
// Pending invitations section
.invitations-section {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.invitations-header {
// Invitation row styles
.invitation-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: spacing.$unit-2x spacing.$unit-3x;
background: rgba(0, 0, 0, 0.02);
border: none;
cursor: pointer;
padding: spacing.$unit spacing.$unit spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
transition: background-color 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
.invitations-title {
display: flex;
align-items: center;
gap: spacing.$unit;
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
}
.invitations-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
background: colors.$error;
color: white;
border-radius: 9px;
font-size: 11px;
font-weight: typography.$medium;
}
.toggle-icon {
font-size: 10px;
color: var(--text-tertiary);
transition: transform 0.2s;
&.open {
transform: rotate(180deg);
}
}
.invitations-list {
list-style: none;
margin: 0;
padding: spacing.$unit;
}
.invitation-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
&:hover {
background: rgba(0, 0, 0, 0.02);
background: rgba(0, 0, 0, 0.03);
}
&.expired {