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:
parent
7dcb100412
commit
4745baca1c
4 changed files with 350 additions and 110 deletions
|
|
@ -205,6 +205,26 @@ export class CrewAdapter extends BaseAdapter {
|
|||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
263
src/lib/components/crew/BulkPhantomModal.svelte
Normal file
263
src/lib/components/crew/BulkPhantomModal.svelte
Normal 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>
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
import {
|
||||
useRemoveMember,
|
||||
useUpdateMembership,
|
||||
useCreatePhantom,
|
||||
useDeletePhantom
|
||||
} from '$lib/api/mutations/crew.mutations'
|
||||
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
|
||||
|
|
@ -19,9 +18,9 @@
|
|||
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 Input from '$lib/components/ui/Input.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.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 type { MemberFilter, CrewMembership, PhantomPlayer, CrewInvitation } from '$lib/types/api/crew'
|
||||
import type { PageData } from './$types'
|
||||
|
|
@ -36,7 +35,7 @@
|
|||
|
||||
// Get filter from URL
|
||||
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
|
||||
|
|
@ -76,21 +75,20 @@
|
|||
// Mutations
|
||||
const removeMemberMutation = useRemoveMember()
|
||||
const updateMembershipMutation = useUpdateMembership()
|
||||
const createPhantomMutation = useCreatePhantom()
|
||||
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' },
|
||||
{ value: 'all', label: 'All' }
|
||||
{ value: 'retired', label: 'Retired' }
|
||||
]
|
||||
|
||||
// Change filter
|
||||
function handleFilterChange(newFilter: MemberFilter) {
|
||||
const url = new URL($page.url)
|
||||
if (newFilter === 'active') {
|
||||
if (newFilter === 'all') {
|
||||
url.searchParams.delete('filter')
|
||||
} else {
|
||||
url.searchParams.set('filter', newFilter)
|
||||
|
|
@ -116,11 +114,11 @@
|
|||
let invitationsSectionOpen = $state(true)
|
||||
|
||||
// Dialog state for phantom creation
|
||||
let phantomDialogOpen = $state(false)
|
||||
let phantomName = $state('')
|
||||
let phantomGranblueId = $state('')
|
||||
let phantomNotes = $state('')
|
||||
let phantomJoinedAt = $state('')
|
||||
let bulkPhantomDialogOpen = $state(false)
|
||||
|
||||
// Dialog state for phantom deletion confirmation
|
||||
let deletePhantomDialogOpen = $state(false)
|
||||
let phantomToDelete = $state<PhantomPlayer | null>(null)
|
||||
|
||||
// Role display helpers
|
||||
function getRoleLabel(role: string): string {
|
||||
|
|
@ -241,45 +239,25 @@
|
|||
editJoinDate = ''
|
||||
}
|
||||
|
||||
// Phantom actions
|
||||
function openPhantomDialog() {
|
||||
phantomName = ''
|
||||
phantomGranblueId = ''
|
||||
phantomNotes = ''
|
||||
phantomJoinedAt = ''
|
||||
phantomDialogOpen = true
|
||||
function openDeletePhantomDialog(phantom: PhantomPlayer) {
|
||||
phantomToDelete = phantom
|
||||
deletePhantomDialogOpen = true
|
||||
}
|
||||
|
||||
async function handleCreatePhantom() {
|
||||
if (!phantomName.trim() || !crewStore.crew) 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
|
||||
async function handleConfirmDeletePhantom() {
|
||||
if (!crewStore.crew || !phantomToDelete) return
|
||||
|
||||
try {
|
||||
await deletePhantomMutation.mutateAsync({
|
||||
crewId: crewStore.crew.id,
|
||||
phantomId: phantom.id
|
||||
phantomId: phantomToDelete.id
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to delete phantom:', error)
|
||||
}
|
||||
|
||||
deletePhantomDialogOpen = false
|
||||
phantomToDelete = null
|
||||
}
|
||||
|
||||
// Format date
|
||||
|
|
@ -337,8 +315,11 @@
|
|||
<Button variant="secondary" size="small" iconOnly icon="ellipsis" {...props} />
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={openPhantomDialog}>
|
||||
Add Phantom
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item"
|
||||
onclick={() => (bulkPhantomDialogOpen = true)}
|
||||
>
|
||||
Add phantoms...
|
||||
</DropdownMenuBase.Item>
|
||||
{/snippet}
|
||||
</DropdownMenu>
|
||||
|
|
@ -504,7 +485,7 @@
|
|||
</DropdownMenuBase.Item>
|
||||
<DropdownMenuBase.Item
|
||||
class="dropdown-menu-item danger"
|
||||
onclick={() => handleDeletePhantom(phantom)}
|
||||
onclick={() => openDeletePhantomDialog(phantom)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuBase.Item>
|
||||
|
|
@ -518,8 +499,8 @@
|
|||
<div class="empty-state">
|
||||
<p>No phantom players.</p>
|
||||
{#if crewStore.isOfficer}
|
||||
<Button variant="secondary" size="small" onclick={openPhantomDialog}>
|
||||
Add Phantom
|
||||
<Button variant="secondary" size="small" onclick={() => (bulkPhantomDialogOpen = true)}>
|
||||
Add phantoms...
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -563,69 +544,6 @@
|
|||
{/snippet}
|
||||
</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 -->
|
||||
<Dialog bind:open={editJoinDateDialogOpen}>
|
||||
{#snippet children()}
|
||||
|
|
@ -657,9 +575,33 @@
|
|||
{/snippet}
|
||||
</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 -->
|
||||
{#if crewStore.crew?.id}
|
||||
<ScoutUserModal bind:open={scoutModalOpen} crewId={crewStore.crew.id} />
|
||||
<BulkPhantomModal bind:open={bulkPhantomDialogOpen} crewId={crewStore.crew.id} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -868,7 +810,7 @@
|
|||
|
||||
// Confirm dialog styles
|
||||
.confirm-message {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue