add bulk phantom creation, delete confirmation, default to all filter

- bulk create phantoms with individual join dates
- confirm before deleting phantoms
- reorder filters with All first and as default
This commit is contained in:
Justin Edmund 2025-12-13 23:12:40 -08:00
parent 7dcb100412
commit 4745baca1c
4 changed files with 350 additions and 110 deletions

View file

@ -205,6 +205,26 @@ export class CrewAdapter extends BaseAdapter {
return response.phantom_player return response.phantom_player
} }
/**
* Create multiple phantom players at once (officers only)
*/
async bulkCreatePhantoms(
crewId: string,
phantoms: CreatePhantomPlayerInput[],
options?: RequestOptions
): Promise<PhantomPlayer[]> {
const response = await this.request<{ phantom_players: PhantomPlayer[] }>(
`/crews/${crewId}/phantom_players/bulk_create`,
{
...options,
method: 'POST',
body: JSON.stringify({ phantom_players: phantoms })
}
)
this.clearCache('/crew/members')
return response.phantom_players
}
/** /**
* Update a phantom player * Update a phantom player
*/ */

View file

@ -183,6 +183,21 @@ export function useCreatePhantom() {
})) }))
} }
/**
* Bulk create phantom players mutation
*/
export function useBulkCreatePhantoms() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ crewId, phantoms }: { crewId: string; phantoms: CreatePhantomPlayerInput[] }) =>
crewAdapter.bulkCreatePhantoms(crewId, phantoms),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: crewKeys.membersAll() })
}
}))
}
/** /**
* Update phantom player mutation * Update phantom player mutation
*/ */

View file

@ -0,0 +1,263 @@
<svelte:options runes={true} />
<script lang="ts">
import { untrack } from 'svelte'
import { useBulkCreatePhantoms } from '$lib/api/mutations/crew.mutations'
import Dialog from '$lib/components/ui/Dialog.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte'
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import type { CreatePhantomPlayerInput } from '$lib/types/api/crew'
interface Props {
open: boolean
crewId: string
}
let { open = $bindable(false), crewId }: Props = $props()
const bulkCreateMutation = useBulkCreatePhantoms()
// State for phantom rows
interface PhantomRow {
id: number
name: string
granblueId: string
joinedAt: string
}
// Use a regular variable for ID counter (doesn't need to be reactive)
let nextId = 1
let rows = $state<PhantomRow[]>([createEmptyRow()])
function createEmptyRow(): PhantomRow {
return {
id: nextId++,
name: '',
granblueId: '',
joinedAt: ''
}
}
function addRow() {
rows = [...rows, createEmptyRow()]
}
function removeRow(id: number) {
if (rows.length <= 1) return
rows = rows.filter((row) => row.id !== id)
}
function resetState() {
nextId = 1
rows = [createEmptyRow()]
}
// Check if we have at least one valid phantom (name is required)
const validPhantoms = $derived(rows.filter((row) => row.name.trim()))
const canSubmit = $derived(validPhantoms.length > 0 && !bulkCreateMutation.isPending)
async function handleSubmit() {
if (!canSubmit) return
const phantoms: CreatePhantomPlayerInput[] = validPhantoms.map((row) => ({
name: row.name.trim(),
granblueId: row.granblueId.trim() || undefined,
joinedAt: row.joinedAt || undefined
}))
try {
await bulkCreateMutation.mutateAsync({ crewId, phantoms })
resetState()
open = false
} catch (error) {
console.error('Failed to create phantoms:', error)
}
}
function handleCancel() {
resetState()
open = false
}
// Reset state when modal opens
$effect(() => {
if (open) {
untrack(() => resetState())
}
})
</script>
<Dialog bind:open size="medium">
{#snippet children()}
<ModalHeader
title="Add Phantom Players"
description="Phantom players allow you to track the scores of members without accounts"
/>
<ModalBody>
<div class="phantom-rows">
<div class="row-header">
<span class="col-name">Name</span>
<span class="col-id">Granblue ID</span>
<span class="col-date">Join Date</span>
<span class="col-action"></span>
</div>
{#each rows as row (row.id)}
<div class="phantom-row">
<input
type="text"
class="input name-input"
placeholder="Player name"
bind:value={row.name}
/>
<input
type="text"
class="input id-input"
placeholder="Optional"
bind:value={row.granblueId}
/>
<input type="date" class="input date-input" bind:value={row.joinedAt} />
<button
type="button"
class="remove-btn"
onclick={() => removeRow(row.id)}
disabled={rows.length <= 1}
aria-label="Remove row"
>
<Icon name="close" size={16} />
</button>
</div>
{/each}
</div>
<Button variant="ghost" size="small" leftIcon="plus" onclick={addRow}>
Add Another
</Button>
{#if bulkCreateMutation.isError}
<p class="error-message">
Failed to create phantom players. Please check the inputs and try again.
</p>
{/if}
</ModalBody>
<ModalFooter
onCancel={handleCancel}
cancelDisabled={bulkCreateMutation.isPending}
primaryAction={{
label: bulkCreateMutation.isPending
? 'Creating...'
: `Create ${validPhantoms.length} Phantom${validPhantoms.length !== 1 ? 's' : ''}`,
onclick: handleSubmit,
disabled: !canSubmit
}}
/>
{/snippet}
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.phantom-rows {
display: flex;
flex-direction: column;
gap: spacing.$unit;
margin-bottom: spacing.$unit-2x;
}
.row-header {
display: grid;
grid-template-columns: 1fr 140px 180px 32px;
gap: spacing.$unit;
padding: 0 spacing.$unit-half;
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
}
.phantom-row {
display: grid;
grid-template-columns: 1fr 140px 180px 32px;
gap: spacing.$unit;
align-items: center;
}
.input {
padding: spacing.$unit 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);
width: 100%;
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
background: var(--input-bound-bg-hover);
}
&::placeholder {
color: var(--text-tertiary);
}
}
.date-input {
padding: spacing.$unit-half spacing.$unit;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: layout.$item-corner-small;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
color: var(--text-primary);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
margin: spacing.$unit 0 0;
}
.col-name {
text-align: left;
}
.col-id,
.col-date {
text-align: left;
}
.col-action {
width: 32px;
}
</style>

View file

@ -8,7 +8,6 @@
import { import {
useRemoveMember, useRemoveMember,
useUpdateMembership, useUpdateMembership,
useCreatePhantom,
useDeletePhantom useDeletePhantom
} from '$lib/api/mutations/crew.mutations' } from '$lib/api/mutations/crew.mutations'
import { crewAdapter } from '$lib/api/adapters/crew.adapter' import { crewAdapter } from '$lib/api/adapters/crew.adapter'
@ -19,9 +18,9 @@
import ModalHeader from '$lib/components/ui/ModalHeader.svelte' import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte' import ModalBody from '$lib/components/ui/ModalBody.svelte'
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 CrewHeader from '$lib/components/crew/CrewHeader.svelte' import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
import ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte' import ScoutUserModal from '$lib/components/crew/ScoutUserModal.svelte'
import BulkPhantomModal from '$lib/components/crew/BulkPhantomModal.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui' 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' import type { PageData } from './$types'
@ -36,7 +35,7 @@
// Get filter from URL // Get filter from URL
const filter = $derived<MemberFilter>( const filter = $derived<MemberFilter>(
($page.url.searchParams.get('filter') as MemberFilter) || 'active' ($page.url.searchParams.get('filter') as MemberFilter) || 'all'
) )
// Query for members based on filter // Query for members based on filter
@ -76,21 +75,20 @@
// Mutations // Mutations
const removeMemberMutation = useRemoveMember() const removeMemberMutation = useRemoveMember()
const updateMembershipMutation = useUpdateMembership() const updateMembershipMutation = useUpdateMembership()
const createPhantomMutation = useCreatePhantom()
const deletePhantomMutation = useDeletePhantom() const deletePhantomMutation = useDeletePhantom()
// Filter options // Filter options
const filterOptions: { value: MemberFilter; label: string }[] = [ const filterOptions: { value: MemberFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' }, { value: 'active', label: 'Active' },
{ value: 'phantom', label: 'Phantoms' }, { value: 'phantom', label: 'Phantoms' },
{ value: 'retired', label: 'Retired' }, { value: 'retired', label: 'Retired' }
{ value: 'all', label: 'All' }
] ]
// Change filter // Change filter
function handleFilterChange(newFilter: MemberFilter) { function handleFilterChange(newFilter: MemberFilter) {
const url = new URL($page.url) const url = new URL($page.url)
if (newFilter === 'active') { if (newFilter === 'all') {
url.searchParams.delete('filter') url.searchParams.delete('filter')
} else { } else {
url.searchParams.set('filter', newFilter) url.searchParams.set('filter', newFilter)
@ -116,11 +114,11 @@
let invitationsSectionOpen = $state(true) let invitationsSectionOpen = $state(true)
// Dialog state for phantom creation // Dialog state for phantom creation
let phantomDialogOpen = $state(false) let bulkPhantomDialogOpen = $state(false)
let phantomName = $state('')
let phantomGranblueId = $state('') // Dialog state for phantom deletion confirmation
let phantomNotes = $state('') let deletePhantomDialogOpen = $state(false)
let phantomJoinedAt = $state('') let phantomToDelete = $state<PhantomPlayer | null>(null)
// Role display helpers // Role display helpers
function getRoleLabel(role: string): string { function getRoleLabel(role: string): string {
@ -241,45 +239,25 @@
editJoinDate = '' editJoinDate = ''
} }
// Phantom actions function openDeletePhantomDialog(phantom: PhantomPlayer) {
function openPhantomDialog() { phantomToDelete = phantom
phantomName = '' deletePhantomDialogOpen = true
phantomGranblueId = ''
phantomNotes = ''
phantomJoinedAt = ''
phantomDialogOpen = true
} }
async function handleCreatePhantom() { async function handleConfirmDeletePhantom() {
if (!phantomName.trim() || !crewStore.crew) return if (!crewStore.crew || !phantomToDelete) return
try {
await createPhantomMutation.mutateAsync({
crewId: crewStore.crew.id,
input: {
name: phantomName.trim(),
granblueId: phantomGranblueId.trim() || undefined,
notes: phantomNotes.trim() || undefined,
joinedAt: phantomJoinedAt || undefined
}
})
phantomDialogOpen = false
} catch (error) {
console.error('Failed to create phantom:', error)
}
}
async function handleDeletePhantom(phantom: PhantomPlayer) {
if (!crewStore.crew) return
try { try {
await deletePhantomMutation.mutateAsync({ await deletePhantomMutation.mutateAsync({
crewId: crewStore.crew.id, crewId: crewStore.crew.id,
phantomId: phantom.id phantomId: phantomToDelete.id
}) })
} catch (error) { } catch (error) {
console.error('Failed to delete phantom:', error) console.error('Failed to delete phantom:', error)
} }
deletePhantomDialogOpen = false
phantomToDelete = null
} }
// Format date // Format date
@ -337,8 +315,11 @@
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} /> <Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={openPhantomDialog}> <DropdownMenuBase.Item
Add Phantom class="dropdown-menu-item"
onclick={() => (bulkPhantomDialogOpen = true)}
>
Add phantoms...
</DropdownMenuBase.Item> </DropdownMenuBase.Item>
{/snippet} {/snippet}
</DropdownMenu> </DropdownMenu>
@ -504,7 +485,7 @@
</DropdownMenuBase.Item> </DropdownMenuBase.Item>
<DropdownMenuBase.Item <DropdownMenuBase.Item
class="dropdown-menu-item danger" class="dropdown-menu-item danger"
onclick={() => handleDeletePhantom(phantom)} onclick={() => openDeletePhantomDialog(phantom)}
> >
Delete Delete
</DropdownMenuBase.Item> </DropdownMenuBase.Item>
@ -518,8 +499,8 @@
<div class="empty-state"> <div class="empty-state">
<p>No phantom players.</p> <p>No phantom players.</p>
{#if crewStore.isOfficer} {#if crewStore.isOfficer}
<Button variant="secondary" size="small" onclick={openPhantomDialog}> <Button variant="secondary" size="small" onclick={() => (bulkPhantomDialogOpen = true)}>
Add Phantom Add phantoms...
</Button> </Button>
{/if} {/if}
</div> </div>
@ -563,69 +544,6 @@
{/snippet} {/snippet}
</Dialog> </Dialog>
<!-- Create Phantom Dialog -->
<Dialog bind:open={phantomDialogOpen}>
{#snippet children()}
<ModalHeader
title="Add Phantom Player"
description="Phantom players allow you to track the scores of members without accounts"
/>
<ModalBody>
<div class="modal-form">
<div class="form-fields">
<Input
label="Name"
bind:value={phantomName}
placeholder="Player's name or nickname"
fullWidth
contained
required
/>
<Input
label="Granblue ID"
bind:value={phantomGranblueId}
placeholder="In-game player ID (optional)"
fullWidth
contained
/>
<div class="form-field">
<label for="phantomJoinedAt">Join Date <span class="optional">(optional)</span></label>
<input
id="phantomJoinedAt"
type="date"
bind:value={phantomJoinedAt}
class="date-input"
/>
</div>
<div class="form-field">
<label for="phantomNotes">Notes <span class="optional">(optional)</span></label>
<textarea
id="phantomNotes"
bind:value={phantomNotes}
placeholder="Optional notes about this player"
rows="2"
></textarea>
</div>
</div>
</div>
</ModalBody>
<ModalFooter
onCancel={() => (phantomDialogOpen = false)}
cancelDisabled={createPhantomMutation.isPending}
primaryAction={{
label: createPhantomMutation.isPending ? 'Creating...' : 'Create',
onclick: handleCreatePhantom,
disabled: !phantomName.trim() || createPhantomMutation.isPending
}}
/>
{/snippet}
</Dialog>
<!-- Edit Join Date Dialog --> <!-- Edit Join Date Dialog -->
<Dialog bind:open={editJoinDateDialogOpen}> <Dialog bind:open={editJoinDateDialogOpen}>
{#snippet children()} {#snippet children()}
@ -657,9 +575,33 @@
{/snippet} {/snippet}
</Dialog> </Dialog>
<!-- Delete Phantom Confirmation Dialog -->
<Dialog bind:open={deletePhantomDialogOpen}>
{#snippet children()}
<ModalHeader title="Delete Phantom Player" />
<ModalBody>
<p class="confirm-message">
Are you sure you want to delete {phantomToDelete?.name ?? 'this phantom player'}? This
action cannot be undone.
</p>
</ModalBody>
<ModalFooter
onCancel={() => (deletePhantomDialogOpen = false)}
primaryAction={{
label: 'Delete',
onclick: handleConfirmDeletePhantom,
destructive: true
}}
/>
{/snippet}
</Dialog>
<!-- Scout User Modal --> <!-- Scout User Modal -->
{#if crewStore.crew?.id} {#if crewStore.crew?.id}
<ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} /> <ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} />
<BulkPhantomModal bind:open={bulkPhantomDialogOpen} crewId={crewStore.crew.id} />
{/if} {/if}
<style lang="scss"> <style lang="scss">
@ -868,7 +810,7 @@
// Confirm dialog styles // Confirm dialog styles
.confirm-message { .confirm-message {
color: var(--text-secondary); color: var(--text-primary);
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
} }