diff --git a/src/client/src/app/+state/events/actions/set-editing-event-instances.action.ts b/src/client/src/app/+state/events/actions/set-editing-event-instances.action.ts new file mode 100644 index 0000000..25ddde9 --- /dev/null +++ b/src/client/src/app/+state/events/actions/set-editing-event-instances.action.ts @@ -0,0 +1,57 @@ +import { inject } from '@angular/core'; +import { on } from '@ngrx/store'; +import { produce } from 'immer'; +import { switchMap } from 'rxjs'; + +import { EventAdministrationService } from '../../../api/services'; +import { AuthService } from '../../../services/auth.service'; +import { createHttpAction, handleHttpAction, onHttpAction } from '../../action-state'; +import { createFunctionalEffect } from '../../functional-effect'; +import { Effects, Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { eventEntityAdapter, EventsFeatureState } from '../events.state'; + +export const setEditingEventInstancesAction = createHttpAction< + { eventId: string; isEditing: boolean }, + { userIdEditingInstances: string | null } +>()(EVENTS_ACTION_SCOPE, 'Set Editing Event Instances'); + +export const setEditingEventInstancesReducers: Reducers = [ + on(setEditingEventInstancesAction.success, (state, { props, response }) => + eventEntityAdapter.mapOne( + { + id: props.eventId, + map: produce(draft => { + draft.userIdEditingInstances = response.userIdEditingInstances; + }), + }, + state + ) + ), + handleHttpAction('setInstancesEditing', setEditingEventInstancesAction), +]; + +export const setEditingEventInstancesEffects: Effects = { + setEditingEventInstances$: createFunctionalEffect.dispatching( + (api = inject(EventAdministrationService), authService = inject(AuthService)) => + onHttpAction(setEditingEventInstancesAction).pipe( + switchMap(({ props }) => setEditingEventInstances(api, props, authService)) + ) + ), +}; + +async function setEditingEventInstances( + api: EventAdministrationService, + props: ReturnType['props'], + authService: AuthService +) { + const response = await api.setEventInstancesEditing({ + eventId: props.eventId, + body: { isEditing: props.isEditing }, + }); + return response.ok + ? setEditingEventInstancesAction.success(props, { + userIdEditingInstances: props.isEditing ? (authService.user()?.id ?? null) : null, + }) + : setEditingEventInstancesAction.error(props, response); +} diff --git a/src/client/src/app/+state/events/actions/set-event-instances.action.ts b/src/client/src/app/+state/events/actions/set-event-instances.action.ts new file mode 100644 index 0000000..60e3111 --- /dev/null +++ b/src/client/src/app/+state/events/actions/set-event-instances.action.ts @@ -0,0 +1,60 @@ +import { inject } from '@angular/core'; +import { on } from '@ngrx/store'; +import { produce, castDraft } from 'immer'; +import { switchMap } from 'rxjs'; + +import { ApiEventTimeslotInstances } from '../../../api/models'; +import { EventAdministrationService } from '../../../api/services'; +import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state'; +import { createFunctionalEffect } from '../../functional-effect'; +import { Effects, Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { eventEntityAdapter, EventsFeatureState } from '../events.state'; + +export const setEventInstancesAction = createHttpAction<{ + eventId: string; + instances: ApiEventTimeslotInstances[]; +}>()(EVENTS_ACTION_SCOPE, 'Set Event Instances'); + +export const setEventInstancesReducers: Reducers = [ + on(setEventInstancesAction.success, (state, { props }) => + eventEntityAdapter.mapOne( + { + id: props.eventId, + map: produce(draft => { + for (const timeslot of draft.timeslots) { + timeslot.instances = castDraft( + props.instances.find(x => x.timeslotId === timeslot.id)?.instances || [] + ); + } + }), + }, + state + ) + ), + handleHttpAction('setInstances', setEventInstancesAction), +]; + +export const setEventInstancesEffects: Effects = { + setEventInstances$: createFunctionalEffect.dispatching( + (api = inject(EventAdministrationService)) => + onHttpAction(setEventInstancesAction).pipe( + switchMap(({ props }) => + toHttpAction(setEventInstances(api, props), setEventInstancesAction, props) + ) + ) + ), +}; + +async function setEventInstances( + api: EventAdministrationService, + props: ReturnType['props'] +) { + const response = await api.putEventInstances({ + eventId: props.eventId, + body: { instances: props.instances }, + }); + return response.ok + ? setEventInstancesAction.success(props, undefined) + : setEventInstancesAction.error(props, response); +} diff --git a/src/client/src/app/+state/events/events.actions.ts b/src/client/src/app/+state/events/events.actions.ts index d4424e1..c53a1dd 100644 --- a/src/client/src/app/+state/events/events.actions.ts +++ b/src/client/src/app/+state/events/events.actions.ts @@ -1,16 +1,18 @@ export { addEventPreconfigAction } from './actions/add-event-preconfig.action'; export { addEventTimeslotAction } from './actions/add-event-timeslot.action'; export { addEventAction } from './actions/add-event.action'; -export { updateEventAction } from './actions/update-event.action'; -export { eventTimeslotRegistrationChangedAction } from './actions/event-timeslot-registration-changed.action'; export { addPlayerToEventPreconfigurationAction } from './actions/add-player-to-preconfig.action'; export { buildEventInstancesAction } from './actions/build-event-instances.action'; +export { eventTimeslotRegistrationChangedAction } from './actions/event-timeslot-registration-changed.action'; export { loadEventAction } from './actions/load-event.action'; export { loadEventsAction } from './actions/load-events.action'; -export { removeEventAction } from './actions/remove-event.action'; export { removeEventPreconfigAction } from './actions/remove-event-preconfig.action'; export { removeEventTimeslotAction } from './actions/remove-event-timeslot.action'; +export { removeEventAction } from './actions/remove-event.action'; export { removePlayerFromPreconfigAction } from './actions/remove-player-from-preconfig.action'; export { resetEventsActionStateAction } from './actions/reset-events-action-state.action'; +export { setEditingEventInstancesAction } from './actions/set-editing-event-instances.action'; +export { setEventInstancesAction } from './actions/set-event-instances.action'; export { startEventAction } from './actions/start-event.action'; export { updateEventTimeslotAction } from './actions/update-event-timeslot.action'; +export { updateEventAction } from './actions/update-event.action'; diff --git a/src/client/src/app/+state/events/events.effects.ts b/src/client/src/app/+state/events/events.effects.ts index b7e068c..2e3b302 100644 --- a/src/client/src/app/+state/events/events.effects.ts +++ b/src/client/src/app/+state/events/events.effects.ts @@ -12,6 +12,11 @@ import { removeEventPreconfigEffects } from './actions/remove-event-preconfig.ac import { removeEventTimeslotEffects } from './actions/remove-event-timeslot.action'; import { removeEventAction, removeEventEffects } from './actions/remove-event.action'; import { removePlayerFromPreconfigEffects } from './actions/remove-player-from-preconfig.action'; +import { + setEditingEventInstancesAction, + setEditingEventInstancesEffects, +} from './actions/set-editing-event-instances.action'; +import { setEventInstancesEffects } from './actions/set-event-instances.action'; import { startEventEffects } from './actions/start-event.action'; import { updateEventTimeslotEffects } from './actions/update-event-timeslot.action'; import { updateEventEffects } from './actions/update-event.action'; @@ -35,6 +40,8 @@ export const eventsFeatureEffects: Effects[] = [ removeEventTimeslotEffects, removeEventEffects, removePlayerFromPreconfigEffects, + setEditingEventInstancesEffects, + setEventInstancesEffects, startEventEffects, updateEventEffects, updateEventTimeslotEffects, @@ -66,6 +73,16 @@ export const eventsFeatureEffects: Effects[] = [ map(event => eventTimeslotRegistrationChangedAction(event)) ) ), + eventInstancesEditorChanged$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).eventInstancesEditorChanged.pipe( + map(event => + setEditingEventInstancesAction.success( + { eventId: event.eventId, isEditing: !!event.userId }, + { userIdEditingInstances: event.userId ?? null } + ) + ) + ) + ), onServerReconnected$: createFunctionalEffect.dispatching(() => inject(RealtimeEventsService).onReconnected$.pipe( diff --git a/src/client/src/app/+state/events/events.reducer.ts b/src/client/src/app/+state/events/events.reducer.ts index 2e679f1..0e20837 100644 --- a/src/client/src/app/+state/events/events.reducer.ts +++ b/src/client/src/app/+state/events/events.reducer.ts @@ -13,6 +13,8 @@ import { removeEventTimeslotReducers } from './actions/remove-event-timeslot.act import { removeEventReducers } from './actions/remove-event.action'; import { removePlayerFromPreconfigReducers } from './actions/remove-player-from-preconfig.action'; import { resetEventsActionStateReducers } from './actions/reset-events-action-state.action'; +import { setEditingEventInstancesReducers } from './actions/set-editing-event-instances.action'; +import { setEventInstancesReducers } from './actions/set-event-instances.action'; import { startEventReducers } from './actions/start-event.action'; import { updateEventTimeslotReducers } from './actions/update-event-timeslot.action'; import { updateEventReducers } from './actions/update-event.action'; @@ -26,7 +28,6 @@ export const eventsReducer = createReducer( ...addEventReducers, ...addPlayerToEventPreconfigurationReducers, ...buildEventInstancesReducers, - ...updateEventReducers, ...eventTimeslotRegistrationChangedReducers, ...loadEventReducers, ...loadEventsReducers, @@ -35,6 +36,9 @@ export const eventsReducer = createReducer( ...removeEventReducers, ...removePlayerFromPreconfigReducers, ...resetEventsActionStateReducers, + ...setEditingEventInstancesReducers, + ...setEventInstancesReducers, ...startEventReducers, - ...updateEventTimeslotReducers + ...updateEventTimeslotReducers, + ...updateEventReducers ); diff --git a/src/client/src/app/+state/events/events.selectors.ts b/src/client/src/app/+state/events/events.selectors.ts index 5b0bc62..dd1f7fc 100644 --- a/src/client/src/app/+state/events/events.selectors.ts +++ b/src/client/src/app/+state/events/events.selectors.ts @@ -23,7 +23,13 @@ export function selectEventsActionState(action: keyof EventsFeatureState['action export function selectEvent(id: string | null | undefined) { return createDistinctSelector(selectEventsFeature, state => - id ? state.entities[id] ?? null : null + id ? (state.entities[id] ?? null) : null + ); +} + +export function selectEventEditor(id: string | null | undefined) { + return createDistinctSelector(selectEventsFeature, state => + id ? (state.entities[id]?.userIdEditingInstances ?? null) : null ); } @@ -33,7 +39,7 @@ export function selectEventTimeslot( ) { return createDistinctSelector(selectEventsFeature, state => eventId && timeslotId - ? state.entities[eventId]?.timeslots.find(x => x.id === timeslotId) ?? null + ? (state.entities[eventId]?.timeslots.find(x => x.id === timeslotId) ?? null) : null ); } diff --git a/src/client/src/app/+state/events/events.state.ts b/src/client/src/app/+state/events/events.state.ts index b36c097..802ffec 100644 --- a/src/client/src/app/+state/events/events.state.ts +++ b/src/client/src/app/+state/events/events.state.ts @@ -15,6 +15,8 @@ export type EventsFeatureState = EntityState & { addTimeslot: ActionState; removeTimeslot: ActionState; buildInstances: ActionState; + setInstances: ActionState; + setInstancesEditing: ActionState; updateTimeslot: ActionState; addPreconfig: ActionState; removePreconfig: ActionState; @@ -40,6 +42,8 @@ export const initialEventsFeatureState: EventsFeatureState = eventEntityAdapter. addTimeslot: initialActionState, removeTimeslot: initialActionState, buildInstances: initialActionState, + setInstances: initialActionState, + setInstancesEditing: initialActionState, updateTimeslot: initialActionState, addPreconfig: initialActionState, removePreconfig: initialActionState, diff --git a/src/client/src/app/components/events/event-details/event-details.component.html b/src/client/src/app/components/events/event-details/event-details.component.html index 84160be..8e791f8 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.html +++ b/src/client/src/app/components/events/event-details/event-details.component.html @@ -73,41 +73,39 @@

{{ translations.events_facebookLink() }}

-
- - - - -
+ + + +

{{ translations.events_timeslots() }}

- {{ playersAmount() }} + {{ playersAmount() }} @if (!event.startedAt && event.staged) { {{ translations.events_timeslots() }} @if (canBuildInstances()) {

{{ translations.events_groups() }}

- @if (!event.startedAt) { - - } + + @if (!event.startedAt) { + + } + @if (hasInstances()) { + + } +
+ @if (event.userIdEditingInstances) { +
+ + {{ allUsers()[event.userIdEditingInstances]?.alias }} +
+ }
@for (timeslot of timeslots(); track timeslot.id) {
@@ -234,3 +253,5 @@

{{ translations.events_notFound() }}

} }
+ + diff --git a/src/client/src/app/components/events/event-details/event-details.component.ts b/src/client/src/app/components/events/event-details/event-details.component.ts index 75eee72..44f97df 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.ts +++ b/src/client/src/app/components/events/event-details/event-details.component.ts @@ -36,6 +36,7 @@ import { ifTruthy } from '../../../utils/common.utils'; import { compareTimes } from '../../../utils/date.utils'; import { errorToastEffect, selectSignal } from '../../../utils/ngrx.utils'; import { EventFormComponent } from '../event-form/event-form.component'; +import { EventInstancesDialogComponent } from '../event-instances-dialog/event-instances-dialog.component'; import { EventTimeslotDialogComponent } from '../event-timeslot-dialog/event-timeslot-dialog.component'; @Component({ @@ -47,6 +48,7 @@ import { EventTimeslotDialogComponent } from '../event-timeslot-dialog/event-tim CardModule, CommonModule, EventFormComponent, + EventInstancesDialogComponent, EventTimeslotDialogComponent, FormsModule, InputGroupAddonModule, diff --git a/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.html b/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.html new file mode 100644 index 0000000..310e1e5 --- /dev/null +++ b/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.html @@ -0,0 +1,125 @@ + + +
+ @for (x of instances(); track x.timeslot.id) { +
+
+ + + {{ x.timeslot.time.hour | number: '2.0-0' }}:{{ + x.timeslot.time.minute | number: '2.0-0' + }} + +
+ + @for (instance of x.instances; track $index) { +
+
+ + {{ instance.groupCode }} + + +
+
+ @for (playerId of instance.playerIds; track playerId; let pi = $index) { +
+
+ @if (allUsers()[playerId]; as user) { + {{ user.alias }} + } @else { + <{{ translations.events_timeslot_unknownPlayer() }}> + ({{ playerId }}) + } +
+ + +
+ } +
+
+ } +
+ } +
+
+ + + + + +
diff --git a/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts b/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts new file mode 100644 index 0000000..902d88b --- /dev/null +++ b/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts @@ -0,0 +1,266 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { produce } from 'immer'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { ListboxModule } from 'primeng/listbox'; +import { OverlayPanelModule } from 'primeng/overlaypanel'; +import { filter, firstValueFrom, pairwise } from 'rxjs'; + +import { hasActionFailed, isActionBusy, isActionIdle } from '../../../+state/action-state'; +import { + selectEventEditor, + selectEventsActionState, + setEditingEventInstancesAction, + setEventInstancesAction, +} from '../../../+state/events'; +import { userSelectors } from '../../../+state/users'; +import { Event, EventInstance, EventTimeslot } from '../../../models/parsed-models'; +import { AuthService } from '../../../services/auth.service'; +import { Logger } from '../../../services/logger.service'; +import { TranslateService } from '../../../services/translate.service'; +import { notNullish } from '../../../utils/common.utils'; +import { errorToastEffect, selectSignal } from '../../../utils/ngrx.utils'; +import { UserItemComponent } from '../../users/user-item/user-item.component'; + +type EventInstances = { timeslot: EventTimeslot; instances: EventInstance[] }[]; + +@Component({ + selector: 'app-event-instances-dialog', + standalone: true, + imports: [ + ButtonModule, + CommonModule, + DialogModule, + InputGroupModule, + ListboxModule, + OverlayPanelModule, + UserItemComponent, + ], + templateUrl: './event-instances-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventInstancesDialogComponent { + private readonly _store = inject(Store); + private readonly _messageService = inject(MessageService); + private readonly _confirmService = inject(ConfirmationService); + private readonly _authService = inject(AuthService); + protected readonly translations = inject(TranslateService).translations; + + private readonly _removeItem: EventInstance = { + id: '', + groupCode: '', + playerIds: [], + }; + private readonly _actionState = selectSignal(selectEventsActionState('setInstances')); + + protected readonly visible = signal(false); + protected readonly event = signal(null); + protected readonly eventEditedBy = selectSignal( + computed(() => selectEventEditor(this.event()?.id)) + ); + protected readonly instances = signal([]); + protected readonly allUsers = selectSignal(userSelectors.selectEntities); + protected readonly unassignedUsers = computed(() => + Object.fromEntries( + this.instances().map(({ timeslot, instances }) => { + const unassignedUsers = Object.values(this.allUsers()) + .filter(notNullish) + .filter(u => !instances.some(i => i.playerIds.includes(u.id))); + + const groups = [ + { + label: this.translations.events_instancesDialog_registeredPlayers(), + items: unassignedUsers.filter(u => timeslot.playerIds.includes(u.id)), + }, + { + label: this.translations.events_instancesDialog_unregisteredPlayers(), + items: unassignedUsers.filter(u => !timeslot.playerIds.includes(u.id)), + }, + ]; + return [timeslot.id, groups] as const; + }) + ) + ); + protected readonly moveToInstanceOptions = computed(() => + Object.fromEntries( + this.instances().map( + ({ timeslot, instances }) => + [ + timeslot.id, + Object.fromEntries( + instances.map( + instance => + [ + instance.id, + [ + ...(this.instances() + .find(({ timeslot }) => timeslot.id === timeslot.id) + ?.instances.filter(i => i.id !== instance.id) ?? []), + this._removeItem, + ], + ] as [string, EventInstance[]] + ) + ), + ] as const + ) + ) + ); + + protected readonly isBusy = computed(() => isActionBusy(this._actionState())); + + constructor() { + toObservable(this.visible) + .pipe( + pairwise(), + takeUntilDestroyed(), + filter(([prev, curr]) => prev && !curr) + ) + .subscribe(() => { + const event = this.event(); + if (event && this.eventEditedBy() === this._authService.user()?.id) { + this._store.dispatch( + setEditingEventInstancesAction({ eventId: event.id, isEditing: false }) + ); + } + this.event.set(null); + this.instances.set([]); + }); + + effect( + () => { + const eventEditedBy = this.eventEditedBy(); + if (eventEditedBy !== this._authService.user()?.id && untracked(() => this.visible())) { + this.visible.set(false); + const user = eventEditedBy ? this.allUsers()[eventEditedBy] : null; + this._confirmService.confirm({ + message: this.translations.events_removeFromEditingBy(user), + rejectVisible: false, + acceptIcon: 'no-icon', + acceptLabel: this.translations.shared_ok(), + }); + } + }, + { allowSignalWrites: true } + ); + + errorToastEffect(this.translations.events_error_changeGroups, this._actionState); + + const actions$ = inject(Actions); + actions$ + .pipe( + ofType(setEventInstancesAction.success), + filter(({ props }) => props.eventId === this.event()?.id), + takeUntilDestroyed() + ) + .subscribe(() => this.visible.set(false)); + } + + public async open(event: Event) { + if (event.userIdEditingInstances) { + const user = this.allUsers()[event.userIdEditingInstances]; + const result = await new Promise(resolve => { + this._confirmService.confirm({ + icon: 'i-[mdi--account-lock]', + header: this.translations.events_userIsEditingAlreadyTitle(), + message: this.translations.events_userIsEditingAlready(user), + acceptLabel: this.translations.events_removeUserFromEditing(user), + acceptButtonStyleClass: 'p-button-danger', + rejectLabel: this.translations.shared_cancel(), + rejectButtonStyleClass: 'p-button-text', + accept: () => resolve(true), + reject: () => resolve(false), + }); + }); + if (!result) return; + } + + this._store.dispatch(setEditingEventInstancesAction({ eventId: event.id, isEditing: true })); + const result = await firstValueFrom( + this._store + .select(selectEventsActionState('setInstancesEditing')) + .pipe(filter(x => isActionIdle(x))) + ); + if (hasActionFailed(result)) { + this._messageService.add({ + severity: 'error', + summary: this.translations.events_error_setEditingInstances(), + detail: this.translations.shared_tryAgainLater(), + life: 7500, + }); + return; + } + + this.event.set(event); + this.instances.set( + event.timeslots.map(timeslot => ({ timeslot, instances: timeslot.instances })) + ); + this.visible.set(true); + } + + protected addPlayer(timeslotId: string, instanceId: string, playerId: string) { + Logger.logDebug('EventInstancesDialogComponent', 'addPlayer', { + timeslotId, + instanceId, + playerId, + }); + this.instances.update( + produce(draft => { + const timeslot = draft.find(({ timeslot }) => timeslot.id === timeslotId); + const instance = timeslot?.instances.find(i => i.id === instanceId); + instance?.playerIds.push(playerId); + }) + ); + } + + protected movePlayer( + timeslotId: string, + playerId: string, + oldInstanbceId: string, + newInstanceId: string + ) { + Logger.logDebug('EventInstancesDialogComponent', 'movePlayer', { + timeslotId, + playerId, + oldInstanbceId, + newInstanceId, + }); + this.instances.update( + produce(draft => { + const timeslot = draft.find(({ timeslot }) => timeslot.id === timeslotId); + const oldInstance = timeslot?.instances.find(i => i.id === oldInstanbceId); + const newInstance = timeslot?.instances.find(i => i.id === newInstanceId); + oldInstance?.playerIds.splice(oldInstance.playerIds.indexOf(playerId), 1); + newInstance?.playerIds.push(playerId); + }) + ); + } + + protected submit() { + const event = this.event(); + const instances = this.instances(); + Logger.logDebug('EventInstancesDialogComponent', 'submit', { event, instances }); + + if (!event) return; + + this._store.dispatch( + setEventInstancesAction({ + eventId: event.id, + instances: instances.map(x => ({ timeslotId: x.timeslot.id, instances: x.instances })), + }) + ); + } +} diff --git a/src/client/src/app/components/user-settings/user-settings.component.html b/src/client/src/app/components/user-settings/user-settings.component.html index a03017a..8a4fee5 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.html +++ b/src/client/src/app/components/user-settings/user-settings.component.html @@ -158,8 +158,7 @@

{{ translations.settings_notifications_notify_title() }}

- - +