Applique .gitattributes sur tous les fichiers existants. Élimine les différences fantômes entre WSL et Windows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
10 KiB
TypeScript
Executable File
255 lines
10 KiB
TypeScript
Executable File
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;
|
|
}
|
|
}
|