dd-timer/src/presentation/components/Dashboard.ts
POL Mickaël 62ae4c54eb feat: design Obsidienne + composants UI + toast/undo/backup
- Design system glassmorphism avec Material Design 3
- 14 composants : App, Sidebar, Dashboard, EnclosView, DragodindeCard,
  AccouplementView, ReapproView, InventaireView, WorkflowsView,
  StatistiquesView, ParametresView, UpdateBanner, Toast, ConfirmModal
- UndoManager pour annulation des actions destructives (Ctrl+Z)
- Toast notifications (success/error) avec bouton Annuler
- Modale de confirmation glassmorphism (remplace confirm() natif)
- Export/import global des données depuis Paramètres
- Maquettes HTML/PNG de la refonte graphique

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 05:43:20 +02:00

255 lines
10 KiB
TypeScript

import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { UIState } from '@presentation/state/UIState';
import type { DashboardResult } from '@application/queries/GetDashboard';
import type { Enclos } from '@domain/entities/Enclos';
import { MAX_DD } from '@domain/entities/Enclos';
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
import { raceColor } from '@domain/value-objects/Race';
import { enclosGlobalState, enclosGaugeCurGl } from '@presentation/helpers/gauge-live';
import { esc, fmtClock } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
export class Dashboard {
private el: HTMLElement | null = null;
private lastRenderTime = 0;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private uiState: UIState,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'dash-new';
container.appendChild(this.el);
this.renderAll();
}
private getData(): DashboardResult {
return this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
}
private renderAll(): void {
if (!this.el) return;
const data = this.getData();
// ── KPI Section ──────────────────────────────────────────────
const activeDD = data.enclosSummaries.reduce((s, e) => s + e.ddCount, 0);
const racesObtained = Object.keys(data.raceBreakdown).length;
const kpis = [
{ label: 'Total Bébés', value: String(data.totalBabies) },
{ label: 'Dragodindes Actives', value: String(activeDD) },
{ label: 'Couples Formés', value: String(data.totalCouples) },
{ label: 'Taux de Réussite', value: `${data.successRate}%` },
{ label: 'Races Obtenues', value: String(racesObtained) },
];
let kpiHtml = `
<section>
<div class="dash-section-hd">
<span class="dash-section-lbl">Statistiques Globales</span>
<button class="dash-reset-btn-new" id="dash-reset-btn">
<span class="material-symbols-outlined">restart_alt</span>
Réinitialiser
</button>
</div>
<div class="dash-kpi-grid-new">
`;
for (const kpi of kpis) {
kpiHtml += `
<div class="dash-kpi-card">
<p class="dash-kpi-lbl">${esc(kpi.label)}</p>
<span class="dash-kpi-val">${esc(kpi.value)}</span>
</div>
`;
}
kpiHtml += `</div></section>`;
// ── Two-column section ───────────────────────────────────────
let enclosHtml = `
<section>
<div class="dash-section-hd">
<span class="dash-section-lbl">Aperçu — Tous les enclos</span>
</div>
<div class="dash-enc-grid">
`;
for (const summary of data.enclosSummaries) {
const enc = this.queryBus.execute<Enclos | null>({ type: 'get-enclos-detail', enclosId: summary.id });
const gs = enc ? enclosGlobalState(enc) : null;
const started = !!enc?.timer.startTime;
const running = !!enc?.timer.running;
const allDone = !!gs?.allDone && started;
// Status
let statusClass = 'idle';
let statusLabel = 'Inactif';
if (running) { statusClass = 'running'; statusLabel = 'Actif'; }
else if (started) { statusClass = 'paused'; statusLabel = 'Pause'; }
const cardClass = `dash-enc-card${running ? ' running' : ''}${allDone ? ' done-enc' : ''}`;
// Gauge tags — détecte les jauges vides en cours de session
const gaugeTags = summary.activeGauges.map(gid => {
const def = GAUGE_DEFS[gid as keyof typeof GAUGE_DEFS];
if (!def) return '';
const cssVar = `var(${def.cssVar})`;
const curGl = (enc && started) ? enclosGaugeCurGl(enc, gid as any) : (enc?.gaugeLevels[gid as keyof typeof enc.gaugeLevels] ?? 0);
const isEmpty = curGl <= 0 && started;
if (isEmpty) {
return `<span class="dash-enc-gauge-tag dash-gauge-empty"
style="background:rgba(234,179,8,0.12);border-color:rgba(234,179,8,0.3);color:#eab308">
${esc(def.label)}
</span>`;
}
return `<span class="dash-enc-gauge-tag"
style="background:color-mix(in srgb, ${cssVar} 10%, transparent);border-color:color-mix(in srgb, ${cssVar} 30%, transparent);color:${cssVar}">
${def.icon} ${esc(def.label)}
</span>`;
}).join('');
const gaugesRow = summary.activeGauges.length > 0
? `<div class="dash-enc-gauges-row">${gaugeTags}</div>`
: `<div class="dash-enc-gauges-row"><span class="dash-enc-no-gauge">Aucune jauge active</span></div>`;
// Sous-label capacité max (uniquement quand l'enclos est plein)
const capaciteLabel = summary.ddCount >= MAX_DD ? 'Capacit\u00e9 max' : '';
const cdText = started && gs ? (gs.allDone ? '✅' : (!isFinite(gs.globalMax) ? '∞' : fmtClock(gs.globalMax))) : '--:--:--';
const elText = started && gs ? fmtClock(gs.el) : '--:--:--';
// Button style: active = primary button if running and approaching end
const btnClass = allDone ? 'dash-enc-btn btn-active' : 'dash-enc-btn';
enclosHtml += `
<div class="${cardClass}" id="dash-enc-${summary.id}" data-enc-id="${summary.id}">
<div class="dash-enc-header-row">
<span class="dash-enc-name-new">${esc(summary.name.toUpperCase())}</span>
<div class="dash-enc-status-badge ${statusClass}">
<span class="dash-enc-dot ${statusClass}"></span>
${esc(statusLabel)}
</div>
</div>
<div class="dash-enc-meta-row">
<div class="dash-enc-dd-block">
<div class="dd-count-big">${summary.ddCount} <span style="font-family:'Inter',sans-serif;font-size:14px;color:rgba(176,168,182,0.6);font-weight:500;margin-left:4px">DD</span></div>
${capaciteLabel ? `<div class="dd-count-sub">${capaciteLabel}</div>` : ''}
</div>
<div class="dash-enc-times">
<div class="dash-enc-time-row primary">
<span class="material-symbols-outlined">hourglass_top</span>
Restant : <span id="dash-cd-${summary.id}">${cdText}</span>
</div>
<div class="dash-enc-time-row muted">
<span class="material-symbols-outlined">schedule</span>
Écoulé : <span id="dash-el-${summary.id}">${elText}</span>
</div>
</div>
</div>
${gaugesRow}
<button class="${btnClass}" data-enc-id="${summary.id}">
Gérer cet enclos
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
`;
}
enclosHtml += `</div></section>`;
// ── Race progression panel (right col) ───────────────────────
const raceEntries = Object.entries(data.raceBreakdown).sort((a, b) => b[1] - a[1]);
const maxCount = raceEntries.length > 0 ? raceEntries[0][1] : 1;
let raceHtml = `
<div>
<div class="dash-section-hd">
<span class="dash-section-lbl">Progression des races</span>
</div>
<div class="dash-race-panel">
`;
if (raceEntries.length === 0) {
raceHtml += `<p class="dash-race-empty">Aucune race enregistrée</p>`;
} else {
for (const [race, count] of raceEntries) {
const pct = maxCount > 0 ? (count / maxCount) * 100 : 0;
const col = raceColor(race);
raceHtml += `
<div class="dash-race-row">
<div class="dash-race-row-hd">
<span style="color:${col}">${esc(race)}</span>
<span style="color:rgba(176,168,182,0.7)">${count}</span>
</div>
<div class="dash-race-bar-bg">
<div class="dash-race-bar-fill" style="width:${pct}%;background:${col}"></div>
</div>
</div>
`;
}
}
raceHtml += `</div></div>`;
// ── Assemble ──────────────────────────────────────────────────
this.el.innerHTML =
kpiHtml +
`<div class="dash-two-col">` +
`<div>${enclosHtml}</div>` +
raceHtml +
`</div>`;
this.bindEvents();
}
private bindEvents(): void {
if (!this.el) return;
// "Gérer" buttons — navigate with stopPropagation
this.el.querySelectorAll<HTMLElement>('button[data-enc-id]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = Number(btn.dataset['encId']);
if (id) this.uiState.setActiveView(id);
});
});
// Card click (excluding button area)
this.el.querySelectorAll<HTMLElement>('.dash-enc-card[id^="dash-enc-"]').forEach(card => {
card.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('button')) return;
const id = Number(card.id.replace('dash-enc-', ''));
if (id) this.uiState.setActiveView(id);
});
});
// Reset stats
this.el.querySelector('#dash-reset-btn')?.addEventListener('click', async () => {
const ok = await ConfirmModal.show('Réinitialiser les statistiques', 'Toutes les statistiques seront effacées. Continuer ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Reset statistiques');
this.commandBus.execute({ type: 'reset-stats' });
Toast.show('success', 'Statistiques réinitialisées.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
this.renderAll();
});
}
update(): void {
if (!this.el) return;
const now = Date.now();
if (now - this.lastRenderTime >= 1000) {
this.lastRenderTime = now;
this.renderAll();
}
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}