add scout button and pending invitations to crew members
- wire scout button to open ScoutUserModal - collapsible section showing sent invitations for officers
This commit is contained in:
parent
013c1b5eb2
commit
82c3f3c471
1 changed files with 194 additions and 4 deletions
|
|
@ -21,8 +21,9 @@
|
||||||
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
|
||||||
import Input from '$lib/components/ui/Input.svelte'
|
import Input from '$lib/components/ui/Input.svelte'
|
||||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||||
|
import ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte'
|
||||||
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
|
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
|
||||||
import type { MemberFilter, CrewMembership, PhantomPlayer } from '$lib/types/api/crew'
|
import type { MemberFilter, CrewMembership, PhantomPlayer, CrewInvitation } from '$lib/types/api/crew'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -47,6 +48,12 @@
|
||||||
enabled: filter !== 'active' // Only fetch separately if not already viewing active
|
enabled: filter !== 'active' // Only fetch separately if not already viewing active
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Query for pending invitations (officers only)
|
||||||
|
const invitationsQuery = createQuery(() => ({
|
||||||
|
...crewQueries.crewInvitations(crewStore.crew?.id ?? ''),
|
||||||
|
enabled: crewStore.isOfficer && !!crewStore.crew?.id
|
||||||
|
}))
|
||||||
|
|
||||||
// Calculate total active roster size (members + phantoms)
|
// Calculate total active roster size (members + phantoms)
|
||||||
const activeRosterSize = $derived.by(() => {
|
const activeRosterSize = $derived.by(() => {
|
||||||
// Use active filter data if viewing active, otherwise use dedicated query
|
// Use active filter data if viewing active, otherwise use dedicated query
|
||||||
|
|
@ -102,6 +109,12 @@
|
||||||
let editingPhantom = $state<PhantomPlayer | null>(null)
|
let editingPhantom = $state<PhantomPlayer | null>(null)
|
||||||
let editJoinDate = $state('')
|
let editJoinDate = $state('')
|
||||||
|
|
||||||
|
// Dialog state for scout modal
|
||||||
|
let scoutModalOpen = $state(false)
|
||||||
|
|
||||||
|
// Pending invitations section visibility
|
||||||
|
let invitationsSectionOpen = $state(true)
|
||||||
|
|
||||||
// Dialog state for phantom creation
|
// Dialog state for phantom creation
|
||||||
let phantomDialogOpen = $state(false)
|
let phantomDialogOpen = $state(false)
|
||||||
let phantomName = $state('')
|
let phantomName = $state('')
|
||||||
|
|
@ -187,14 +200,14 @@
|
||||||
editingMember = member
|
editingMember = member
|
||||||
editingPhantom = null
|
editingPhantom = null
|
||||||
// Format date for input
|
// Format date for input
|
||||||
editJoinDate = member.joinedAt ? member.joinedAt.split('T')[0] : ''
|
editJoinDate = member.joinedAt ? member.joinedAt.split('T')[0] ?? '' : ''
|
||||||
editJoinDateDialogOpen = true
|
editJoinDateDialogOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditPhantomJoinDateDialog(phantom: PhantomPlayer) {
|
function openEditPhantomJoinDateDialog(phantom: PhantomPlayer) {
|
||||||
editingPhantom = phantom
|
editingPhantom = phantom
|
||||||
editingMember = null
|
editingMember = null
|
||||||
editJoinDate = phantom.joinedAt ? phantom.joinedAt.split('T')[0] : ''
|
editJoinDate = phantom.joinedAt ? phantom.joinedAt.split('T')[0] ?? '' : ''
|
||||||
editJoinDateDialogOpen = true
|
editJoinDateDialogOpen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,6 +290,16 @@
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if invitation is expired
|
||||||
|
function isInvitationExpired(expiresAt: string): boolean {
|
||||||
|
return new Date(expiresAt) < new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending (non-expired) invitations count
|
||||||
|
const pendingInvitationsCount = $derived(
|
||||||
|
invitationsQuery.data?.filter((inv) => !isInvitationExpired(inv.expiresAt)).length ?? 0
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -301,7 +324,14 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if crewStore.isOfficer}
|
{#if crewStore.isOfficer}
|
||||||
<Button variant="secondary" size="small" disabled={isRosterFull}>Scout</Button>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
disabled={isRosterFull}
|
||||||
|
onclick={() => (scoutModalOpen = true)}
|
||||||
|
>
|
||||||
|
Scout
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
{#snippet trigger({ props })}
|
{#snippet trigger({ props })}
|
||||||
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
|
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
|
||||||
|
|
@ -316,6 +346,49 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CrewHeader>
|
</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}
|
{#if membersQuery.isLoading}
|
||||||
<div class="loading-state">
|
<div class="loading-state">
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
|
|
@ -602,6 +675,11 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Scout User Modal -->
|
||||||
|
{#if crewStore.crew?.id}
|
||||||
|
<ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use '$src/themes/colors' as colors;
|
@use '$src/themes/colors' as colors;
|
||||||
@use '$src/themes/effects' as effects;
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
@ -900,4 +978,116 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending invitations section
|
||||||
|
.invitations-section {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitations-header {
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expired {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invited-user {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invited-by {
|
||||||
|
font-size: typography.$font-tiny;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expires-text {
|
||||||
|
font-size: typography.$font-tiny;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.expired {
|
||||||
|
font-size: typography.$font-tiny;
|
||||||
|
color: colors.$error;
|
||||||
|
background: colors.$error--bg--light;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue