256 lines
8.8 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
}
|