Files
Bookie/BookieClient/src/app/features/crud/crud-page.component.ts
2026-09-17 21:45:57 +02:00

256 lines
8.8 KiB
TypeScript

import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { ApiService } from '../../core/services/api.service';
import { CrudConfig, ColumnConfig, FilterConfig, RowAction, SelectOption } from './crud-config';
import { MatchDetailsDialogComponent } from '../pre-match/match-details-dialog.component';
import { HeadToHeadDialogComponent } from '../pre-match/head-to-head-dialog.component';
import { PlayerStatsDialogComponent } from '../stats/player-stats-dialog.component';
import { TeamStatsDialogComponent } from '../stats/team-stats-dialog.component';
@Component({
selector: 'app-crud-page',
imports: [
DatePipe, MatCardModule, MatTableModule, MatPaginatorModule, MatSortModule, MatFormFieldModule,
MatInputModule, MatSelectModule, MatDatepickerModule, MatButtonModule, MatIconModule,
MatProgressBarModule, MatChipsModule, MatTooltipModule,
],
templateUrl: './crud-page.component.html',
styleUrl: './crud-page.component.scss',
})
export class CrudPageComponent {
private route = inject(ActivatedRoute);
private api = inject(ApiService);
private dialog = inject(MatDialog);
readonly config = signal<CrudConfig | null>(null);
readonly rows = signal<any[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly page = signal(0);
readonly pageSize = signal(10);
readonly sortBy = signal<string | undefined>(undefined);
readonly sortDesc = signal(false);
private search = '';
readonly filters = signal<Record<string, string>>({});
readonly lookupOptions = signal<Record<string, SelectOption[]>>({});
readonly displayedColumns = signal<string[]>([]);
/** Unified filter definitions: explicit config.filters, else derived from filterable columns. */
readonly filterDefs = computed<FilterConfig[]>(() => {
const cfg = this.config();
if (!cfg) return [];
if (cfg.filters?.length) return cfg.filters;
return cfg.columns
.filter((c) => !!c.filter)
.map((c) => ({
key: c.filterKey ?? c.key,
label: c.label,
type: c.filter!,
options: c.filterOptions,
}));
});
readonly hasActiveFilters = computed(() => Object.keys(this.filters()).length > 0);
private searchInput$ = new Subject<string>();
private filterChange$ = new Subject<void>();
constructor() {
this.searchInput$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((term) => {
this.search = term;
this.page.set(0);
this.load();
});
this.filterChange$.pipe(debounceTime(300)).subscribe(() => {
this.page.set(0);
this.load();
});
this.route.data.subscribe((data) => {
const cfg = data['config'] as CrudConfig;
this.config.set(cfg);
this.search = '';
this.filters.set({});
this.page.set(0);
this.sortBy.set(cfg.defaultSort);
this.sortDesc.set(cfg.defaultSortDesc ?? false);
this.updateColumns();
this.loadLookups();
this.load();
});
}
private loadLookups() {
this.lookupOptions.set({});
this.filterDefs()
.filter((f) => f.type === 'lookup' && f.lookup)
.forEach((f) => this.loadLookup(f));
}
/** Loads options for one lookup filter, scoped by the current values of its dependencies. */
private loadLookup(def: FilterConfig) {
if (def.type !== 'lookup' || !def.lookup) return;
const params: Record<string, string> = {};
for (const dep of def.dependsOn ?? []) {
const v = this.filters()[dep];
if (v) params[dep] = v;
}
this.api.lookup(def.lookup, params).subscribe((items) => {
this.lookupOptions.update((cur) => ({
...cur,
[def.lookup!]: items.map((i) => ({ value: String(i.id), label: i.name })),
}));
});
}
optionsFor(def: FilterConfig): SelectOption[] {
if (def.type === 'lookup' && def.lookup) return this.lookupOptions()[def.lookup] ?? [];
return def.options ?? [];
}
private updateColumns() {
const cfg = this.config();
if (!cfg) return;
const cols = cfg.columns.map((c) => c.key);
if (cfg.rowActions?.length) cols.push('view');
this.displayedColumns.set(cols);
}
load() {
const cfg = this.config();
if (!cfg) return;
this.loading.set(true);
this.api.crud(cfg.resource).getPaged({
page: this.page() + 1,
pageSize: this.pageSize(),
search: this.search || undefined,
sortBy: this.sortBy(),
sortDesc: this.sortDesc(),
filters: this.filters(),
}).subscribe({
next: (res) => {
this.rows.set(res.items);
this.total.set(res.totalCount);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
onSearch(value: string) { this.searchInput$.next(value); }
setFilter(def: FilterConfig, value: string | number) {
const next = { ...this.filters() };
if (value == null || value === '') delete next[def.key];
else next[def.key] = String(value);
// Reset any filters that depend on this one (their previous choice may now be invalid).
const dependents = this.filterDefs().filter((f) => f.dependsOn?.includes(def.key));
for (const dep of dependents) delete next[dep.key];
this.filters.set(next);
// Reload dependent lookups with the new scope.
for (const dep of dependents) this.loadLookup(dep);
this.filterChange$.next();
}
filterValue(def: FilterConfig): string {
return this.filters()[def.key] ?? '';
}
/** For date filters: current value as a Date (for the datepicker) or null. */
dateValue(def: FilterConfig): Date | null {
const v = this.filters()[def.key];
return v ? new Date(v) : null;
}
/** Stores a picked date as YYYY-MM-DD (local), avoiding timezone shifts. */
setDateFilter(def: FilterConfig, value: Date | null) {
if (!value) { this.setFilter(def, ''); return; }
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
this.setFilter(def, `${y}-${m}-${d}`);
}
clearFilters() {
this.filters.set({});
this.page.set(0);
this.loadLookups();
this.load();
}
onPage(e: PageEvent) {
this.page.set(e.pageIndex);
this.pageSize.set(e.pageSize);
this.load();
}
onSort(e: Sort) {
this.sortBy.set(e.direction ? e.active : this.config()?.defaultSort);
this.sortDesc.set(e.direction === 'desc');
this.load();
}
// ---- Clickable cells + row actions ----
visibleActions(row: any): RowAction[] {
return (this.config()?.rowActions ?? []).filter((a) => !a.showWhen || a.showWhen(row));
}
openLink(col: ColumnConfig, row: any) {
if (!col.link) return;
const id = row[col.link.idKey];
if (id == null) return;
if (col.link.entity === 'player') {
this.dialog.open(PlayerStatsDialogComponent, { data: { playerId: id }, width: '600px', maxWidth: '95vw', autoFocus: false });
} else if (col.link.entity === 'team') {
this.dialog.open(TeamStatsDialogComponent, { data: { teamId: id }, width: '640px', maxWidth: '95vw', autoFocus: false });
} else if (col.link.entity === 'match') {
this.dialog.open(MatchDetailsDialogComponent, { data: { matchId: id }, width: '640px', maxWidth: '95vw', autoFocus: false });
}
}
runAction(action: RowAction, row: any) {
if (action.action === 'match-stats') {
this.dialog.open(MatchDetailsDialogComponent, {
data: { matchId: row.matchId }, width: '640px', maxWidth: '95vw', autoFocus: false,
});
} else if (action.action === 'head-to-head') {
this.dialog.open(HeadToHeadDialogComponent, {
data: {
teamAId: row.homeTeamId,
teamBId: row.awayTeamId,
teamAName: row.homeTeamName,
teamBName: row.awayTeamName,
beforeMatchId: row.matchId,
},
width: '560px', maxWidth: '95vw', autoFocus: false,
});
}
}
}