feat: Enhance event management with FightEdit and GroupEdit components, including improved data handling and new functionalities

This commit is contained in:
2025-05-22 19:41:49 +02:00
parent 1da279bb24
commit 5277c9a3fc
9 changed files with 371 additions and 112 deletions

View File

@ -1,45 +1,36 @@
<script lang="ts">
import type { EventFight } from "@type/event";
import type { EventFight, EventFightEdit, ResponseGroups, UpdateEventGroup, GroupUpdateEdit, SWEvent } from "@type/event";
import { fromAbsolute, now, ZonedDateTime } from "@internationalized/date";
import { Label } from "@components/ui/label";
import DateTimePicker from "@components/ui/datetime-picker/DateTimePicker.svelte";
import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover";
import { gamemodes, maps } from "@components/stores/stores";
import { CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, Command } from "@components/ui/command";
import { ChevronsUpDown, Check } from "lucide-svelte";
import { ChevronsUpDown, Check, ChevronsUpDownIcon, PlusIcon, CheckIcon, MinusIcon } from "lucide-svelte";
import { Button } from "@components/ui/button";
import { cn } from "@components/utils";
import type { Team } from "@components/types/team";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
import type { Snippet } from "svelte";
import { Input } from "@components/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import { eventRepo } from "@components/repo/event";
import GroupEdit from "./GroupEdit.svelte";
const {
fight,
teams,
event,
actions,
onSave,
groups,
}: {
fight: EventFight | null;
teams: Team[];
event: SWEvent;
groups: ResponseGroups[];
actions: Snippet<[boolean, () => void]>;
onSave: (fight: {
spielmodus: string;
map: string;
blueTeam: {
id: number;
name: string;
kuerzel: string;
color: string;
};
redTeam: {
id: number;
name: string;
kuerzel: string;
color: string;
};
start: number;
ergebnis: number;
}) => void;
onSave: (fight: EventFightEdit) => void;
} = $props();
let fightModus = $state(fight?.spielmodus);
@ -48,33 +39,63 @@
let fightRedTeam = $state(fight?.redTeam);
let fightStart = $state(fight?.start ? fromAbsolute(fight.start, "Europe/Berlin") : now("Europe/Berlin"));
let fightErgebnis = $state(fight?.ergebnis ?? 0);
let fightSpectatePort = $state(fight?.spectatePort?.toString() ?? null);
let fightGroup = $state(fight?.group?.id ?? null);
let selectedGroup = $derived(groups.find((group) => group.id === fightGroup));
let mapsStore = $derived(maps(fightModus ?? "null"));
let gamemodeSelectOpen = $state(false);
let mapSelectOpen = $state(false);
let blueTeamSelectOpen = $state(false);
let redTeamSelectOpen = $state(false);
let createOpen = $state(false);
let groupSelectOpen = $state(false);
let dirty = $derived(
fightModus !== fight?.spielmodus ||
fightMap !== fight?.map ||
fightBlueTeam !== fight?.blueTeam ||
fightRedTeam !== fight?.redTeam ||
fightBlueTeam?.id !== fight?.blueTeam?.id ||
fightRedTeam?.id !== fight?.redTeam?.id ||
fightStart.toDate().getTime() !== fight?.start ||
fightErgebnis !== fight?.ergebnis
fightErgebnis !== fight?.ergebnis ||
fightSpectatePort !== (fight?.spectatePort?.toString() ?? null) ||
fightGroup !== (fight?.group?.id ?? null)
);
function submit() {
onSave({
let loading = $state(false);
async function submit() {
loading = true;
try {
await onSave({
spielmodus: fightModus!,
map: fightMap!,
blueTeam: fightBlueTeam!,
redTeam: fightRedTeam!,
start: fightStart?.toDate().getTime(),
ergebnis: fightErgebnis,
spectatePort: fightSpectatePort ? +fightSpectatePort : null,
group: fightGroup,
});
} finally {
loading = false;
}
}
async function handleGroupSave(group: GroupUpdateEdit) {
let g = await $eventRepo.createGroup(event.id.toString(), group);
groups.push(g);
fightGroup = g.id;
createOpen = false;
groupSelectOpen = false;
}
</script>
<div class="flex flex-col gap-2">
<Label for="fight-modus">Modus</Label>
<Popover>
<Popover bind:open={gamemodeSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
@ -94,6 +115,7 @@
value={modus}
onSelect={() => {
fightModus = modus;
gamemodeSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", modus !== fightModus && "text-transparent")} />
@ -106,7 +128,7 @@
</PopoverContent>
</Popover>
<Label for="fight-map">Map</Label>
<Popover>
<Popover bind:open={mapSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
@ -126,6 +148,7 @@
value={map}
onSelect={() => {
fightMap = map;
mapSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", map !== fightMap && "text-transparent")} />
@ -138,7 +161,7 @@
</PopoverContent>
</Popover>
<Label for="fight-blue-team">Blue Team</Label>
<Popover>
<Popover bind:open={blueTeamSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
@ -158,6 +181,7 @@
value={team.name}
onSelect={() => {
fightBlueTeam = team;
blueTeamSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", team !== fightBlueTeam && "text-transparent")} />
@ -170,7 +194,7 @@
</PopoverContent>
</Popover>
<Label for="fight-red-team">Red Team</Label>
<Popover>
<Popover bind:open={redTeamSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button variant="outline" class="justify-between" {...props} role="combobox">
@ -190,6 +214,7 @@
value={team.name}
onSelect={() => {
fightRedTeam = team;
redTeamSelectOpen = false;
}}
>
<Check class={cn("mr-2 size-4", team !== fightRedTeam && "text-transparent")} />
@ -203,6 +228,7 @@
</Popover>
<Label>Start</Label>
<DateTimePicker bind:value={fightStart} />
{#if fight !== null}
<Label for="fight-ergebnis">Ergebnis</Label>
<Select type="single" value={fightErgebnis?.toString()} onValueChange={(v) => (fightErgebnis = +v)}>
<SelectTrigger>
@ -214,6 +240,76 @@
<SelectItem value={"2"}>{fightRedTeam?.name ?? "Team Blau"} gewinnt</SelectItem>
</SelectContent>
</Select>
{/if}
<Label for="fight-group">Gruppe</Label>
<Dialog bind:open={createOpen}>
<Popover bind:open={groupSelectOpen}>
<PopoverTrigger>
{#snippet child({ props })}
<Button id="fight-group" variant="outline" class="justify-between" {...props} role="combobox">
{selectedGroup?.name || "Keine Gruppe"}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="p-0">
<Command>
<CommandInput placeholder="Gruppe suchen..." />
<CommandList>
<CommandGroup>
<CommandItem value={"new"} onSelect={() => (createOpen = true)}>
<PlusIcon class={"mr-2 size-4"} />
Neue Gruppe
</CommandItem>
<CommandItem
value={"none"}
onSelect={() => {
fightGroup = null;
groupSelectOpen = false;
}}
>
{#if fightGroup === null}
<CheckIcon class={"mr-2 size-4"} />
{:else}
<MinusIcon class={"mr-2 size-4"} />
{/if}
Keine Gruppe
</CommandItem>
{#each groups as group}
<CommandItem
value={group.id.toString()}
onSelect={() => {
fightGroup = group.id;
groupSelectOpen = false;
}}
>
<CheckIcon class={cn("mr-2 size-4", fightGroup !== group.id && "text-transparent")} />
{group.name}
</CommandItem>
{/each}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Gruppe erstellen</DialogTitle>
<DialogDescription>Hier kannst du eine neue Gruppe erstellen</DialogDescription>
</DialogHeader>
<GroupEdit group={null} onSave={handleGroupSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</GroupEdit>
</DialogContent>
</Dialog>
<Label for="spectate-port">Spectate Port</Label>
<Input id="spectate-port" bind:value={fightSpectatePort} type="number" placeholder="2001" />
</div>
{@render actions(dirty, submit)}
{@render actions(dirty && !loading, submit)}

View File

@ -0,0 +1,78 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ResponseGroups, GroupUpdateEdit } from "@type/event";
import { Label } from "@components/ui/label";
import { Input } from "@components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@components/ui/select";
const {
group,
actions,
onSave,
}: {
group: ResponseGroups | null;
actions: Snippet<[boolean, () => void]>;
onSave: (groupData: GroupUpdateEdit) => void;
} = $props();
let groupName = $state(group?.name ?? "");
let groupType = $state(group?.type ?? "GROUP_STAGE");
let pointsPerWin = $state(group?.pointsPerWin ?? 3);
let pointsPerLoss = $state(group?.pointsPerLoss ?? 0);
let pointsPerDraw = $state(group?.pointsPerDraw ?? 1);
let canSave = $derived(groupName.length > 0 && (groupType === "GROUP_STAGE" || groupType === "ELIMINATION_STAGE") && pointsPerWin !== null && pointsPerLoss !== null && pointsPerDraw !== null);
let dirty = $derived(
groupName !== (group ? group.name : "") ||
groupType !== (group ? group.type : "GROUP_STAGE") ||
pointsPerWin !== (group ? group.pointsPerWin : 3) ||
pointsPerLoss !== (group ? group.pointsPerLoss : 0) ||
pointsPerDraw !== (group ? group.pointsPerDraw : 1)
);
function submit() {
onSave({
name: groupName,
type: groupType,
pointsPerWin: pointsPerWin,
pointsPerLoss: pointsPerLoss,
pointsPerDraw: pointsPerDraw,
});
}
</script>
<div class="flex flex-col gap-2">
<Label for="group-name">Name</Label>
<Input id="group-name" bind:value={groupName} placeholder="z.B. Gruppenphase A" />
<Label for="group-type">Typ</Label>
<Select
value={groupType}
type="single"
onValueChange={(v) => {
if (v) groupType = v as "GROUP_STAGE" | "ELIMINATION_STAGE";
}}
>
<SelectTrigger id="group-type" placeholder="Wähle einen Gruppentyp">
{groupType === "GROUP_STAGE" ? "Gruppenphase" : "Eliminierungsphase"}
</SelectTrigger>
<SelectContent>
<SelectItem value="GROUP_STAGE">Gruppenphase</SelectItem>
<SelectItem value="ELIMINATION_STAGE">Eliminierungsphase</SelectItem>
</SelectContent>
</Select>
{#if groupType === "GROUP_STAGE" && group !== null}
<Label for="points-win">Punkte pro Sieg</Label>
<Input id="points-win" type="number" bind:value={pointsPerWin} placeholder="3" />
<Label for="points-loss">Punkte pro Niederlage</Label>
<Input id="points-loss" type="number" bind:value={pointsPerLoss} placeholder="0" />
<Label for="points-draw">Punkte pro Unentschieden</Label>
<Input id="points-draw" type="number" bind:value={pointsPerDraw} placeholder="1" />
{/if}
</div>
{@render actions(group === null ? canSave : dirty, submit)}

View File

@ -22,25 +22,29 @@
import GroupEditRow from "./GroupEditRow.svelte";
import type { ExtendedEvent } from "@type/event";
import type { EventFightEdit, ExtendedEvent } from "@type/event";
import { createSvelteTable, FlexRender } from "@components/ui/data-table";
import { type ColumnFiltersState, getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getSortedRowModel, type RowSelectionState, type SortingState } from "@tanstack/table-core";
import { columns } from "./columns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table";
import { Checkbox } from "@components/ui/checkbox";
import { Menubar, MenubarContent, MenubarItem, MenubarGroup, MenubarGroupHeading, MenubarMenu, MenubarSeparator, MenubarTrigger } from "@components/ui/menubar";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import { Button } from "@components/ui/button";
import { MenuIcon } from "lucide-svelte";
import { eventRepo } from "@components/repo/event";
let { data = $bindable() }: { data: ExtendedEvent } = $props();
let fights = $state(data.fights);
let sorting = $state<SortingState>([]);
let columnFilters = $state<ColumnFiltersState>([]);
let selection = $state<RowSelectionState>({});
const table = createSvelteTable({
get data() {
return data.fights;
return fights;
},
initialState: {
columnOrder: ["auswahl", "begegnung", "group"],
@ -88,9 +92,23 @@
groupedColumnMode: "remove",
getRowId: (row) => row.id.toString(),
});
let createOpen = $state(false);
async function handleSave(fight: EventFightEdit) {
await $eventRepo.createFight(data.event.id.toString(), {
...fight,
blueTeam: fight.blueTeam.id,
redTeam: fight.redTeam.id,
});
fights = await $eventRepo.listFights(data.event.id.toString());
createOpen = false;
}
</script>
<div class="w-fit">
<Dialog bind:open={createOpen}>
<Menubar>
<MenubarMenu>
<MenubarTrigger>Mehrfach Bearbeiten</MenubarTrigger>
@ -103,7 +121,7 @@
<MenubarMenu>
<MenubarTrigger>Erstellen</MenubarTrigger>
<MenubarContent>
<MenubarItem disabled>Fight Erstellen</MenubarItem>
<MenubarItem onclick={() => (createOpen = true)}>Fight Erstellen</MenubarItem>
<MenubarGroup>
<MenubarGroupHeading>Generatoren</MenubarGroupHeading>
<MenubarItem disabled>Gruppenphase</MenubarItem>
@ -112,6 +130,20 @@
</MenubarContent>
</MenubarMenu>
</Menubar>
<DialogContent>
<DialogHeader>
<DialogTitle>Fight Erstellen</DialogTitle>
<DialogDescription>Hier kannst du einen neuen Fight erstellen</DialogDescription>
</DialogHeader>
<FightEdit fight={null} teams={data.teams} event={data.event} groups={data.groups} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
</DialogFooter>
{/snippet}
</FightEdit>
</DialogContent>
</Dialog>
</div>
<Table>
@ -131,7 +163,7 @@
<TableBody>
{#each table.getRowModel().rows as groupRow (groupRow.id)}
{#if groupRow.getIsGrouped()}
<TableRow class="bg-muted font-bold">
<TableRow class="font-bold">
<TableCell colspan={columns.length - 1}>
<Checkbox
checked={groupRow.getIsSelected()}
@ -155,7 +187,13 @@
</TableCell>
{/each}
<TableCell class="text-right">
<FightEditRow fight={row.original} teams={data.teams}></FightEditRow>
<FightEditRow
fight={row.original}
teams={data.teams}
groups={data.groups}
event={data.event}
onupdate={(update) => (fights = fights.map((v) => (v.id === update.id ? update : v)))}
></FightEditRow>
</TableCell>
</TableRow>
{/each}

View File

@ -24,7 +24,7 @@
import RefereesList from "@components/moderator/pages/event/RefereesList.svelte";
import TeamTable from "@components/moderator/pages/event/TeamTable.svelte";
let { event }: { event: ExtendedEvent } = $props();
let { event = $bindable() }: { event: ExtendedEvent } = $props();
</script>
<div class="flex flex-col m-4 p-4 rounded-md border gap-4">

View File

@ -1,21 +1,32 @@
<script lang="ts">
import type { EventFight } from "@type/event";
import type { EventFight, EventFightEdit, ResponseGroups, SWEvent } from "@type/event";
import { Button } from "@components/ui/button";
import { EditIcon, MenuIcon } from "lucide-svelte";
import { EditIcon, MenuIcon, GroupIcon } from "lucide-svelte";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog";
import FightEdit from "@components/moderator/components/FightEdit.svelte";
import type { Team } from "@components/types/team";
import { fightRepo } from "@components/repo/fight";
const { fight, teams }: { fight: EventFight; teams: Team[] } = $props();
let { fight, teams, groups, event, onupdate }: { fight: EventFight; teams: Team[]; groups: ResponseGroups[]; event: SWEvent; onupdate: (update: EventFight) => void } = $props();
function handleSave(fightData) {
// Handle the save action here
console.log("Fight data saved:", fightData);
let editOpen = $state(false);
async function handleSave(fightData: EventFightEdit) {
let f = await $fightRepo.updateFight(event.id, fight.id, {
...fightData,
blueTeam: fightData.blueTeam.id,
redTeam: fightData.redTeam.id,
group: fightData.group ?? -1,
});
onupdate(f);
editOpen = false;
}
</script>
<div>
<Dialog>
<Dialog bind:open={editOpen}>
<DialogTrigger>
<Button variant="ghost" size="icon">
<EditIcon />
@ -26,7 +37,7 @@
<DialogTitle>Fight bearbeiten</DialogTitle>
<DialogDescription>Hier kannst du die Daten des Kampfes bearbeiten.</DialogDescription>
</DialogHeader>
<FightEdit {fight} {teams} onSave={handleSave}>
<FightEdit {fight} {teams} {groups} {event} onSave={handleSave}>
{#snippet actions(dirty, submit)}
<DialogFooter>
<Button disabled={!dirty} onclick={submit}>Speichern</Button>
@ -35,7 +46,4 @@
</FightEdit>
</DialogContent>
</Dialog>
<Button variant="ghost" size="icon">
<MenuIcon />
</Button>
</div>

View File

@ -77,4 +77,28 @@ export const columns: ColumnDef<EventFight> = [
});
},
},
{
header: "Spielmodus",
accessorKey: "spielmodus",
},
{
header: "Map",
accessorKey: "map",
},
{
header: "Ergebnis",
accessorKey: "ergebnis",
cell: ({ row }) => {
const fight = row.original;
if (fight.ergebnis === 0 && fight.start > Date.now()) {
return "Noch nicht gespielt";
} else if (fight.ergebnis === 1) {
return fight.blueTeam.name + " hat gewonnen";
} else if (fight.ergebnis === 2) {
return fight.redTeam.name + " hat gewonnen";
} else {
return "Unentschieden";
}
},
},
];

View File

@ -128,6 +128,7 @@ export class EventRepo {
.then((value) => z.array(EventFightSchema).parse(value));
}
public async createFight(eventId: string, fight: any): Promise<EventFight> {
delete fight.ergebnis;
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST",
body: JSON.stringify(fight),
@ -153,7 +154,10 @@ export class EventRepo {
CreateEventGroupSchema.parse(group);
return await fetchWithToken(this.token, `/events/${eventId}/groups`, {
method: "POST",
body: JSON.stringify(group),
body: JSON.stringify({
name: group.name,
type: group.type,
}),
headers: { "Content-Type": "application/json" },
})
.then((value) => value.json())

View File

@ -17,12 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EventFight} from "@type/event.js";
import {fetchWithToken, tokenStore} from "./repo";
import {z} from "zod";
import {EventFightSchema} from "@type/event.js";
import type {Dayjs} from "dayjs";
import {derived} from "svelte/store";
import type { EventFight } from "@type/event.js";
import { fetchWithToken, tokenStore } from "./repo";
import { z } from "zod";
import { EventFightSchema } from "@type/event.js";
import type { Dayjs } from "dayjs";
import { derived } from "svelte/store";
export interface CreateFight {
spielmodus: string;
@ -39,23 +39,22 @@ export interface UpdateFight {
map: string | null;
blueTeam: number | null;
redTeam: number | null;
start: Dayjs | null;
start: number | null;
spectatePort: number | null;
group: string | null;
group: number | null;
}
export class FightRepo {
constructor(private token: string) {
}
constructor(private token: string) {}
public async listFights(eventId: number): Promise<EventFight[]> {
return await fetchWithToken(this.token, `/events/${eventId}/fights`)
.then(value => value.json())
.then(value => z.array(EventFightSchema).parse(value));
.then((value) => value.json())
.then((value) => z.array(EventFightSchema).parse(value));
}
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
return await fetchWithToken(this.token, "/fights", {
return await fetchWithToken(this.token, `/events/${eventId}/fights`, {
method: "POST",
body: JSON.stringify({
event: eventId,
@ -67,28 +66,25 @@ export class FightRepo {
spectatePort: fight.spectatePort,
group: fight.group,
}),
}).then(value => value.json())
})
.then((value) => value.json())
.then(EventFightSchema.parse);
}
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
return await fetchWithToken(this.token, `/fights/${fightId}`, {
public async updateFight(eventId: number, fightId: number, fight: UpdateFight): Promise<EventFight> {
return await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "PUT",
body: JSON.stringify({
spielmodus: fight.spielmodus,
map: fight.map,
blueTeam: fight.blueTeam,
redTeam: fight.redTeam,
...fight,
start: fight.start?.valueOf(),
spectatePort: fight.spectatePort,
group: fight.group,
}),
}).then(value => value.json())
})
.then((value) => value.json())
.then(EventFightSchema.parse);
}
public async deleteFight(fightId: number): Promise<void> {
const res = await fetchWithToken(this.token, `/fights/${fightId}`, {
public async deleteFight(eventId: number, fightId: number): Promise<void> {
const res = await fetchWithToken(this.token, `/events/${eventId}/fights/${fightId}`, {
method: "DELETE",
});

View File

@ -45,6 +45,15 @@ export const EventFightSchema = z.object({
export type EventFight = z.infer<typeof EventFightSchema>;
export const EventFightEditSchema = EventFightSchema.omit({
id: true,
group: true,
}).extend({
group: z.number().nullable(),
});
export type EventFightEdit = z.infer<typeof EventFightEditSchema>;
export type ResponseGroups = z.infer<typeof ResponseGroupsSchema>;
export const ResponseRelationSchema = z.object({
@ -111,6 +120,12 @@ export const UpdateEventGroupSchema = z.object({
});
export type UpdateEventGroup = z.infer<typeof UpdateEventGroupSchema>;
export const GroupEditSchema = ResponseGroupsSchema.omit({
id: true,
points: true,
});
export type GroupUpdateEdit = z.infer<typeof GroupEditSchema>;
export const CreateEventRelationSchema = z.object({
fightId: z.number(),
team: z.enum(["RED", "BLUE"]),