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(null); readonly rows = signal([]); readonly total = signal(0); readonly loading = signal(false); readonly page = signal(0); readonly pageSize = signal(10); readonly sortBy = signal(undefined); readonly sortDesc = signal(false); private search = ''; readonly filters = signal>({}); readonly lookupOptions = signal>({}); readonly displayedColumns = signal([]); /** Unified filter definitions: explicit config.filters, else derived from filterable columns. */ readonly filterDefs = computed(() => { 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(); private filterChange$ = new Subject(); 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 = {}; 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, }); } } }