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 { Sidebar } from './Sidebar';
import { Dashboard } from './Dashboard';
import { EnclosView } from './EnclosView';
import { AccouplementView } from './AccouplementView';
import { ReapproView } from './ReapproView';
import { InventaireView } from './InventaireView';
import { ParametresView } from './ParametresView';
import { WorkflowsView } from './WorkflowsView';
import { StatistiquesView } from './StatistiquesView';
import { UpdateBanner } from './UpdateBanner';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
import { esc } from '@presentation/helpers/format';
import { enclosGlobalState } from '@presentation/helpers/gauge-live';
import { MAX_ENCLOS } from '@domain/entities/Enclos';
import type { Enclos } from '@domain/entities/Enclos';
type ChildComponent = { update(): void; destroy(): void };
export class App {
private root: HTMLElement;
private sidebar: Sidebar;
private updateBanner: UpdateBanner;
private activeChild: ChildComponent | null = null;
private unsubscribe: (() => void) | null = null;
private rafId: number | null = null;
private completionIntervalId: number | null = null;
private ctrlZHandler: ((e: KeyboardEvent) => void) | null = null;
private lastView: string | number | null = null;
// Tab drag-and-drop state
private dragSrcIdx: number | null = null;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private uiState: UIState,
rootElement: HTMLElement,
private playSound?: (name: string) => void,
) {
this.root = rootElement;
this.sidebar = new Sidebar(uiState, queryBus);
this.updateBanner = new UpdateBanner();
}
render(): void {
this.root.innerHTML = `
`;
// Mount sidebar
const sbContainer = this.root.querySelector('#sb-container') as HTMLElement;
this.sidebar.render(sbContainer);
// Mount update banner
const bannerRoot = this.root.querySelector('#update-banner-root') as HTMLElement;
this.updateBanner.render(bannerRoot);
// Mount toast container
const appShell = this.root.querySelector('.app-shell') as HTMLElement;
Toast.mount(appShell);
// Ctrl+Z → undo
this.ctrlZHandler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) {
e.preventDefault();
UndoManager.undo();
}
};
document.addEventListener('keydown', this.ctrlZHandler);
// DEV badge via IPC (pas d'executeJavaScript)
const api = (window as any).electronAPI;
api?.onDevMode?.(() => {
const p = this.root.querySelector('header p');
if (p && !document.getElementById('dev-badge')) {
const b = document.createElement('span');
b.id = 'dev-badge';
b.textContent = 'DEV';
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
p.appendChild(b);
}
});
// Hamburger toggle
const hamburgerBtn = this.root.querySelector('#hamburger-btn') as HTMLElement;
hamburgerBtn.addEventListener('click', () => this.uiState.toggleSidebar());
// Subscribe to UI state changes
this.unsubscribe = this.uiState.subscribe(() => this.onStateChange());
// Initial renders
this.renderTabs();
this.renderContent();
this.updateSidebarState();
// Start animation loop
this.startAnimationLoop();
// Interval indépendant du focus fenêtre pour la détection de fin de session
this.completionIntervalId = window.setInterval(() => {
this.checkAllEnclosCompletion();
}, 1000);
}
private onStateChange(): void {
this.renderTabs();
this.renderContent();
this.sidebar.update();
this.updateSidebarState();
}
private updateSidebarState(): void {
const sidebarEl = this.root.querySelector('.sidebar-new') as HTMLElement | null;
if (sidebarEl) {
sidebarEl.classList.toggle('sidebar-closed', !this.uiState.sidebarOpen);
}
}
private getDashboardData(): DashboardResult {
return this.queryBus.execute({ type: 'get-dashboard' });
}
// ── Tabs ──────────────────────────────────────────────────────
private renderTabs(): void {
const tabsRow = this.root.querySelector('#tabs-row') as HTMLElement | null;
if (!tabsRow) return;
const data = this.getDashboardData();
const enclosList = data.enclosSummaries;
const activeView = this.uiState.activeView;
let html = '';
// Dashboard tab
const dashActive = activeView === 'dashboard' ? ' active' : '';
html += `dashboardDashboard
`;
// Statistiques tab
const statsActive = activeView === 'statistiques' ? ' active' : '';
html += `bar_chartStatistiques
`;
// Enclos tabs
enclosList.forEach((enc, idx) => {
const isActive = activeView === enc.id ? ' active' : '';
const isRunning = enc.running ? ' running' : '';
const canDelete = enclosList.length > 1;
html += ``;
html += ``;
html += `${esc(enc.name)}`;
if (canDelete) {
html += ``;
}
html += `
`;
});
// Add enclos button
const disabled = enclosList.length >= MAX_ENCLOS ? ' disabled' : '';
html += ``;
tabsRow.innerHTML = html;
// Tab click events
tabsRow.querySelectorAll('.tab[data-view]').forEach(tab => {
tab.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('tab-del')) return;
const view = (tab as HTMLElement).dataset['view']!;
const viewValue = /^\d+$/.test(view) ? Number(view) : view;
this.uiState.setActiveView(viewValue);
});
});
// Delete events
tabsRow.querySelectorAll('.tab-del').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = Number((btn as HTMLElement).dataset['deleteId']);
const ok = await ConfirmModal.show('Supprimer l\'enclos', 'Cette action est irréversible. Continuer ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Suppression enclos');
this.commandBus.execute({ type: 'delete-enclos', enclosId: id });
Toast.show('success', 'Enclos supprimé.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
if (this.uiState.activeView === id) {
const newData = this.getDashboardData();
const firstEnclos = newData.enclosSummaries[0];
this.uiState.setActiveView(firstEnclos ? firstEnclos.id : 'dashboard');
} else {
this.uiState.notify();
}
});
});
// Add enclos button
const addBtn = tabsRow.querySelector('#add-enclos-btn') as HTMLElement | null;
if (addBtn) {
addBtn.addEventListener('click', () => {
this.commandBus.execute({ type: 'create-enclos' });
const newData = this.getDashboardData();
const last = newData.enclosSummaries[newData.enclosSummaries.length - 1];
if (last) this.uiState.setActiveView(last.id);
});
}
// Drag and drop
this.setupTabDragAndDrop(tabsRow);
}
private setupTabDragAndDrop(tabsRow: HTMLElement): void {
const tabs = tabsRow.querySelectorAll('.tab[draggable=true]') as NodeListOf;
tabs.forEach(tab => {
tab.addEventListener('dragstart', (e) => {
this.dragSrcIdx = Number(tab.dataset['idx']);
(e as DragEvent).dataTransfer!.effectAllowed = 'move';
tab.classList.add('dragging');
});
tab.addEventListener('dragover', (e) => {
e.preventDefault();
(e as DragEvent).dataTransfer!.dropEffect = 'move';
tab.classList.add('drag-over');
});
tab.addEventListener('dragleave', () => {
tab.classList.remove('drag-over');
});
tab.addEventListener('dragend', () => {
tab.classList.remove('dragging');
tabs.forEach(t => t.classList.remove('drag-over'));
});
tab.addEventListener('drop', (e) => {
e.preventDefault();
tab.classList.remove('drag-over');
const destIdx = Number(tab.dataset['idx']);
if (this.dragSrcIdx !== null && this.dragSrcIdx !== destIdx) {
this.commandBus.execute({
type: 'reorder-enclos',
fromIndex: this.dragSrcIdx,
toIndex: destIdx,
});
this.uiState.notify();
}
this.dragSrcIdx = null;
});
});
}
// ── Content routing ───────────────────────────────────────────
private renderContent(): void {
const view = this.uiState.activeView;
if (view === this.lastView && this.activeChild) {
this.activeChild.update();
return;
}
if (this.activeChild) {
this.activeChild.destroy();
this.activeChild = null;
}
const container = this.root.querySelector('#enclos-content') as HTMLElement | null;
if (!container) return;
container.innerHTML = '';
this.lastView = view;
if (view === 'dashboard') {
const child = new Dashboard(this.commandBus, this.queryBus, this.uiState);
child.render(container);
this.activeChild = child;
} else if (view === 'accouplement') {
const child = new AccouplementView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'appro') {
const child = new ReapproView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'inventaire') {
const child = new InventaireView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'workflows') {
const child = new WorkflowsView(this.commandBus, this.queryBus, this.uiState);
child.render(container);
this.activeChild = child;
} else if (view === 'statistiques') {
const child = new StatistiquesView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'parametres') {
const child = new ParametresView(this.commandBus, this.queryBus, this.playSound);
child.render(container);
this.activeChild = child;
} else if (typeof view === 'number') {
const child = new EnclosView(this.commandBus, this.queryBus, this.uiState);
child.render(container, view);
this.activeChild = child;
}
}
// ── Live update loop ──────────────────────────────────────────
private startAnimationLoop(): void {
const loop = () => {
this.updateTabDots();
if (this.activeChild) this.activeChild.update();
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
}
private updateTabDots(): void {
const data = this.getDashboardData();
data.enclosSummaries.forEach(enc => {
const tab = this.root.querySelector(`#tab-enc-${enc.id}`) as HTMLElement | null;
if (!tab) return;
tab.classList.toggle('running', enc.running);
});
}
/** Appelle complete-timer sur tout enclos dont toutes les cibles sont atteintes. */
private checkAllEnclosCompletion(): void {
const data = this.getDashboardData();
data.enclosSummaries.forEach(summary => {
if (!summary.running) return;
const enc = this.queryBus.execute({ type: 'get-enclos-detail', enclosId: summary.id });
if (!enc.dragodindes.length || !enc.activeGauges.length) return;
const { allDone } = enclosGlobalState(enc);
if (allDone) {
this.commandBus.execute({ type: 'complete-timer', enclosId: summary.id });
}
});
}
destroy(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.completionIntervalId !== null) {
clearInterval(this.completionIntervalId);
this.completionIntervalId = null;
}
if (this.ctrlZHandler) {
document.removeEventListener('keydown', this.ctrlZHandler);
this.ctrlZHandler = null;
}
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
if (this.activeChild) {
this.activeChild.destroy();
this.activeChild = null;
}
this.sidebar.destroy();
this.updateBanner.destroy();
this.root.innerHTML = '';
}
}