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>
472 lines
17 KiB
TypeScript
Executable File
472 lines
17 KiB
TypeScript
Executable File
import type { CommandBus } from '@application/handlers/CommandBus';
|
|
import type { QueryBus } from '@application/handlers/QueryBus';
|
|
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_RECIPES } from '@domain/value-objects/Race';
|
|
import { getDDImage } from '@presentation/helpers/dd-image';
|
|
import { esc } from '@presentation/helpers/format';
|
|
|
|
interface ApproNeeds {
|
|
total: number;
|
|
m: number;
|
|
f: number;
|
|
}
|
|
|
|
interface ApproStep {
|
|
baby: string;
|
|
parentA: string;
|
|
parentB: string;
|
|
couples: number;
|
|
gen: number;
|
|
}
|
|
|
|
interface ApproState {
|
|
target: string;
|
|
qty: number;
|
|
repro: Record<string, number>;
|
|
inverted: Record<string, boolean>;
|
|
genFilter: number;
|
|
search: string;
|
|
}
|
|
|
|
export class ReapproView {
|
|
private el: HTMLElement | null = null;
|
|
private approState: ApproState = { target: '', qty: 1, repro: {}, inverted: {}, genFilter: 0, search: '' };
|
|
private dirty = true;
|
|
|
|
constructor(
|
|
private commandBus: CommandBus,
|
|
private queryBus: QueryBus,
|
|
) {}
|
|
|
|
render(container: HTMLElement): void {
|
|
this.el = document.createElement('div');
|
|
this.el.className = 'reappro-view-new';
|
|
container.appendChild(this.el);
|
|
this.dirty = true; this.update();
|
|
}
|
|
|
|
update(): void {
|
|
if (!this.el || !this.dirty) return;
|
|
this.dirty = false;
|
|
const { target } = this.approState;
|
|
|
|
if (target) {
|
|
this.renderResults();
|
|
} else {
|
|
this.renderTargetSelection();
|
|
}
|
|
}
|
|
|
|
destroy(): void {
|
|
this.el?.remove();
|
|
this.el = null;
|
|
}
|
|
|
|
/* ── Target selection ── */
|
|
private renderTargetSelection(): void {
|
|
if (!this.el) return;
|
|
const { genFilter, search } = this.approState;
|
|
|
|
const allRaces: { name: string; gen: number }[] = [];
|
|
for (const [g, rs] of Object.entries(RACES_DATA)) {
|
|
const gen = parseInt(g);
|
|
if (gen < 2) continue;
|
|
for (const r of rs) allRaces.push({ name: r.name, gen });
|
|
}
|
|
|
|
const q = search.trim().toLowerCase();
|
|
const filtered = allRaces.filter(r =>
|
|
(genFilter > 0 ? r.gen === genFilter : true) &&
|
|
(q ? r.name.toLowerCase().includes(q) : true)
|
|
);
|
|
|
|
let html = '';
|
|
|
|
// Header
|
|
html += `<div class="reappro-section-header">
|
|
<div>
|
|
<h2 class="reappro-section-title">Sélectionne ta cible</h2>
|
|
<div class="reappro-title-bar"></div>
|
|
</div>
|
|
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
|
|
<button class="accoup-gen-chip${genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
|
|
for (let g = 2; g <= 10; g++) {
|
|
html += `<button class="accoup-gen-chip${genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
|
|
}
|
|
html += `</div></div>`;
|
|
|
|
// Search
|
|
html += `<div class="accoup-search-wrap">
|
|
<input class="accoup-search" id="appro-search-input" type="text"
|
|
placeholder="Rechercher une race…" value="${esc(search)}" autocomplete="off">
|
|
${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
|
|
</div>`;
|
|
|
|
// Race grid
|
|
if (filtered.length === 0) {
|
|
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
|
|
} else {
|
|
html += `<div class="accoup-race-grid">`;
|
|
for (const race of filtered) {
|
|
const genCol = GEN_COLORS[race.gen] ?? '#888';
|
|
html += `<div class="accoup-race-card" data-race="${esc(race.name)}">
|
|
<div class="accoup-race-card-img">
|
|
${getDDImage(race.name)}
|
|
<div class="accoup-race-card-gen" style="color:${genCol}">GEN ${race.gen}</div>
|
|
</div>
|
|
<div class="accoup-race-card-name">${esc(race.name)}</div>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
this.el.innerHTML = html;
|
|
this.bindTargetSelectionEvents();
|
|
|
|
if (search) {
|
|
const inp = this.el.querySelector<HTMLInputElement>('#appro-search-input');
|
|
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
|
}
|
|
}
|
|
|
|
private bindTargetSelectionEvents(): void {
|
|
if (!this.el) return;
|
|
|
|
const searchInput = this.el.querySelector<HTMLInputElement>('#appro-search-input');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
this.approState.search = searchInput.value;
|
|
this.dirty = true; this.update();
|
|
});
|
|
}
|
|
|
|
const clearBtn = this.el.querySelector('.accoup-search-clear');
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => {
|
|
this.approState.search = '';
|
|
this.dirty = true; this.update();
|
|
});
|
|
}
|
|
|
|
this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
this.approState.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
|
|
this.dirty = true; this.update();
|
|
});
|
|
});
|
|
|
|
this.el.querySelectorAll('.accoup-race-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
this.approState.target = (card as HTMLElement).dataset.race!;
|
|
this.approState.qty = 1;
|
|
this.approState.repro = {};
|
|
this.approState.inverted = {};
|
|
this.approState.search = '';
|
|
this.dirty = true; this.update();
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ── Results view ── */
|
|
private renderResults(): void {
|
|
if (!this.el) return;
|
|
const { target, qty } = this.approState;
|
|
const targetGen = RACE_GEN[target] ?? 0;
|
|
const { materials, steps } = this.calcAppro(target, qty);
|
|
|
|
// Group steps by generation
|
|
const stepsByGen = new Map<number, ApproStep[]>();
|
|
for (const step of steps) {
|
|
const list = stepsByGen.get(step.gen) ?? [];
|
|
list.push(step);
|
|
stepsByGen.set(step.gen, list);
|
|
}
|
|
const sortedGens = [...stepsByGen.keys()].sort((a, b) => a - b);
|
|
|
|
let html = '';
|
|
|
|
// Top bar: back + quantity (top-left)
|
|
html += `<div class="reappro-top-bar">
|
|
<button class="reappro-back-btn" id="appro-back">
|
|
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
|
|
Retour
|
|
</button>
|
|
<div class="reappro-qty-wrap">
|
|
<label class="accoup-center-label">Quantité</label>
|
|
<input class="accoup-center-input" id="appro-qty" type="number" min="1" value="${qty}" style="width:70px">
|
|
</div>
|
|
</div>`;
|
|
|
|
// Materials (Gen 1)
|
|
if (materials.length > 0) {
|
|
const totalMat = materials.reduce((s, m) => s + m.m + m.f, 0);
|
|
html += `<div class="reappro-step-panel reappro-step-primary">`;
|
|
html += `<div class="reappro-step-header reappro-step-header-primary">
|
|
<h3 class="reappro-step-title">
|
|
<span class="reappro-step-badge reappro-badge-primary">1</span>
|
|
MATIÈRES PREMIÈRES — GÉNÉRATION 1
|
|
</h3>
|
|
<span class="reappro-step-count">Total : ${totalMat} dragodindes requises</span>
|
|
</div>`;
|
|
html += `<div class="reappro-materials-grid">`;
|
|
for (const mat of materials) {
|
|
html += this.renderMaterialCard(mat.race, mat.m, mat.f);
|
|
}
|
|
html += `</div></div>`;
|
|
|
|
// Arrow separator
|
|
html += `<div class="reappro-arrow-sep">
|
|
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
|
|
</div>`;
|
|
}
|
|
|
|
// Steps grouped by generation
|
|
let stepNum = 2;
|
|
for (let gi = 0; gi < sortedGens.length; gi++) {
|
|
const gen = sortedGens[gi];
|
|
const genSteps = stepsByGen.get(gen)!;
|
|
const isFinalGen = gi === sortedGens.length - 1;
|
|
const genCol = GEN_COLORS[gen] ?? '#888';
|
|
|
|
const panelClass = isFinalGen ? 'reappro-step-panel reappro-step-final' : 'reappro-step-panel';
|
|
const headerClass = isFinalGen ? 'reappro-step-header reappro-step-header-final' : 'reappro-step-header';
|
|
const badgeClass = isFinalGen ? 'reappro-step-badge reappro-badge-primary' : 'reappro-step-badge';
|
|
|
|
html += `<div class="${panelClass}">`;
|
|
html += `<div class="${headerClass}">
|
|
<h3 class="reappro-step-title">
|
|
<span class="${badgeClass}">${stepNum}</span>
|
|
${isFinalGen ? 'ÉTAPE FINALE' : 'CROISEMENTS'} — GÉNÉRATION ${gen}
|
|
</h3>
|
|
<span class="reappro-step-count">${genSteps.length} croisement${genSteps.length > 1 ? 's' : ''}</span>
|
|
</div>`;
|
|
|
|
html += `<div class="reappro-crossings-grid">`;
|
|
for (const step of genSteps) {
|
|
const invKey = step.baby;
|
|
const isInverted = this.approState.inverted[invKey] ?? false;
|
|
const reproCount = this.approState.repro[invKey] ?? 0;
|
|
const isLast = isFinalGen && genSteps.length === 1;
|
|
|
|
html += `<div class="reappro-crossing-card${isLast ? ' reappro-crossing-final' : ''}">`;
|
|
html += `<div class="reappro-crossing-row">`;
|
|
|
|
// Parents
|
|
const pA = isInverted ? step.parentB : step.parentA;
|
|
const pB = isInverted ? step.parentA : step.parentB;
|
|
html += this.renderCrossingParent(pA, '♂', step.couples);
|
|
html += `<span class="material-symbols-outlined reappro-crossing-op">add</span>`;
|
|
html += this.renderCrossingParent(pB, '♀', step.couples);
|
|
html += `<span class="material-symbols-outlined reappro-crossing-op" style="color:var(--md-primary)">arrow_forward</span>`;
|
|
|
|
// Baby result
|
|
const babyGen = RACE_GEN[step.baby] ?? 0;
|
|
html += `<div class="reappro-crossing-baby${isLast ? ' reappro-crossing-baby-final' : ''}">
|
|
<div class="reappro-baby-avatar">
|
|
${getDDImage(step.baby)}
|
|
<span class="reappro-baby-gen-badge" style="background:${genCol}">G${babyGen}</span>
|
|
</div>
|
|
<span class="reappro-baby-name">${esc(step.baby)}</span>
|
|
<span class="reappro-baby-qty">×${step.couples}</span>
|
|
</div>`;
|
|
|
|
html += `</div>`; // .reappro-crossing-row
|
|
|
|
// Controls row
|
|
html += `<div class="reappro-crossing-controls">
|
|
<label class="reappro-repro-label">
|
|
<span style="font-size:11px;color:var(--md-on-surface-variant)">Reproducteurs</span>
|
|
<input type="number" class="reappro-repro-input" data-race="${esc(invKey)}" min="0" value="${reproCount}">
|
|
</label>
|
|
<button class="reappro-invert-btn" data-race="${esc(invKey)}" title="Inverser ♂/♀">
|
|
<span class="material-symbols-outlined" style="font-size:16px">swap_horiz</span>
|
|
</button>
|
|
<span class="reappro-couples-badge">${step.couples} couple${step.couples > 1 ? 's' : ''}</span>
|
|
</div>`;
|
|
|
|
html += `</div>`; // .reappro-crossing-card
|
|
}
|
|
html += `</div>`; // .reappro-crossings-grid
|
|
html += `</div>`; // .reappro-step-panel
|
|
|
|
stepNum++;
|
|
|
|
// Arrow between gen panels (not after last)
|
|
if (!isFinalGen) {
|
|
html += `<div class="reappro-arrow-sep">
|
|
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// Sticky target bar at bottom with save button
|
|
html += `<div class="reappro-target-bar-sticky">
|
|
<div class="reappro-target-info">
|
|
<div class="reappro-target-icon">
|
|
<span class="material-symbols-outlined mso-fill">auto_awesome</span>
|
|
</div>
|
|
<div>
|
|
<div class="reappro-target-label">Cible de réapprovisionnement</div>
|
|
<div class="reappro-target-name">${esc(target)} (Génération ${targetGen})</div>
|
|
</div>
|
|
</div>
|
|
<button class="reappro-save-btn" id="appro-save">
|
|
<span class="material-symbols-outlined" style="font-size:18px">save</span>
|
|
Sauvegarder ce plan
|
|
</button>
|
|
</div>`;
|
|
|
|
this.el.innerHTML = html;
|
|
this.bindResultsEvents();
|
|
}
|
|
|
|
private renderMaterialCard(race: string, m: number, f: number): string {
|
|
const gen = RACE_GEN[race] ?? 1;
|
|
const genCol = GEN_COLORS[gen] ?? '#888';
|
|
const parts: string[] = [];
|
|
if (m > 0) parts.push(`<span class="reappro-mat-gender" style="color:#50a0ff">♂ ${m}</span>`);
|
|
if (f > 0) parts.push(`<span class="reappro-mat-gender" style="color:#ff64a0">♀ ${f}</span>`);
|
|
|
|
return `<div class="reappro-material-card">
|
|
<div class="reappro-mat-avatar">
|
|
${getDDImage(race)}
|
|
</div>
|
|
<span class="reappro-mat-name">${esc(race)}</span>
|
|
<div class="reappro-mat-qty">${parts.join(' ')}</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderCrossingParent(race: string, gender: string, qty: number): string {
|
|
const genderColor = gender === '♂' ? '#50a0ff' : '#ff64a0';
|
|
return `<div class="reappro-crossing-parent">
|
|
<div class="reappro-crossing-parent-avatar">
|
|
${getDDImage(race)}
|
|
</div>
|
|
<span class="reappro-crossing-parent-name">${esc(race)}</span>
|
|
<span class="reappro-crossing-parent-gender" style="color:${genderColor}">${gender} ${qty}</span>
|
|
</div>`;
|
|
}
|
|
|
|
private bindResultsEvents(): void {
|
|
if (!this.el) return;
|
|
|
|
// Back buttons
|
|
this.el.querySelectorAll('#appro-back').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
this.approState.target = '';
|
|
this.dirty = true; this.update();
|
|
});
|
|
});
|
|
|
|
const qtyInput = this.el.querySelector('#appro-qty') as HTMLInputElement | null;
|
|
if (qtyInput) {
|
|
let prevQty = qtyInput.value;
|
|
qtyInput.addEventListener('focus', () => { prevQty = qtyInput.value; qtyInput.value = ''; });
|
|
qtyInput.addEventListener('blur', () => {
|
|
if (qtyInput.value === '') qtyInput.value = prevQty;
|
|
this.approState.qty = Math.max(1, parseInt(qtyInput.value) || 1);
|
|
qtyInput.value = String(this.approState.qty);
|
|
this.dirty = true; this.update();
|
|
});
|
|
}
|
|
|
|
this.el.querySelectorAll('.reappro-repro-input').forEach(inp => {
|
|
const inpEl = inp as HTMLInputElement;
|
|
let prev = inpEl.value;
|
|
inpEl.addEventListener('focus', () => { prev = inpEl.value; inpEl.value = ''; });
|
|
inpEl.addEventListener('blur', () => {
|
|
if (inpEl.value === '') inpEl.value = prev;
|
|
const race = inpEl.dataset.race!;
|
|
this.approState.repro[race] = Math.max(0, parseInt(inpEl.value) || 0);
|
|
this.dirty = true; this.update();
|
|
});
|
|
});
|
|
|
|
this.el.querySelectorAll('.reappro-invert-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const race = (btn as HTMLElement).dataset.race!;
|
|
this.approState.inverted[race] = !this.approState.inverted[race];
|
|
this.dirty = true; this.update();
|
|
});
|
|
});
|
|
|
|
const saveBtn = this.el.querySelector('#appro-save');
|
|
if (saveBtn) {
|
|
saveBtn.addEventListener('click', () => {
|
|
const { target, qty, repro } = this.approState;
|
|
const { materials, steps } = this.calcAppro(target, qty);
|
|
this.commandBus.execute({
|
|
type: 'save-workflow',
|
|
target,
|
|
qty,
|
|
materials,
|
|
steps,
|
|
repro: { ...repro },
|
|
});
|
|
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">check</span> Sauvegardé !';
|
|
setTimeout(() => {
|
|
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">save</span> Sauvegarder ce plan';
|
|
}, 2000);
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── Breeding plan calculation ── */
|
|
private calcAppro(target: string, qty: number): { materials: { race: string; m: number; f: number }[]; steps: ApproStep[] } {
|
|
const targetGen = RACE_GEN[target] ?? 0;
|
|
if (targetGen < 2 || !BREEDING_RECIPES[target]) {
|
|
return { materials: [], steps: [] };
|
|
}
|
|
|
|
const needs: Record<string, ApproNeeds> = {};
|
|
needs[target] = { total: qty, m: 0, f: 0 };
|
|
|
|
const steps: ApproStep[] = [];
|
|
|
|
for (let g = targetGen; g >= 2; g--) {
|
|
const racesAtGen: string[] = [];
|
|
for (const [rg, rs] of Object.entries(RACES_DATA)) {
|
|
if (parseInt(rg) === g) {
|
|
for (const r of rs) {
|
|
if (needs[r.name]) racesAtGen.push(r.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const race of racesAtGen) {
|
|
const recipe = BREEDING_RECIPES[race];
|
|
if (!recipe) continue;
|
|
const need = needs[race];
|
|
if (!need || need.total <= 0) continue;
|
|
|
|
const reproCount = this.approState.repro[race] ?? 0;
|
|
const couples = Math.max(1, need.total - reproCount);
|
|
const isInverted = this.approState.inverted[race] ?? false;
|
|
|
|
const [recipeA, recipeB] = recipe;
|
|
const parentA = isInverted ? recipeB : recipeA;
|
|
const parentB = isInverted ? recipeA : recipeB;
|
|
|
|
if (!needs[parentA]) needs[parentA] = { total: 0, m: 0, f: 0 };
|
|
needs[parentA].total += couples;
|
|
needs[parentA].m += couples;
|
|
|
|
if (!needs[parentB]) needs[parentB] = { total: 0, m: 0, f: 0 };
|
|
needs[parentB].total += couples;
|
|
needs[parentB].f += couples;
|
|
|
|
steps.push({ baby: race, parentA, parentB, couples, gen: g });
|
|
}
|
|
}
|
|
|
|
const materials: { race: string; m: number; f: number }[] = [];
|
|
for (const [race, need] of Object.entries(needs)) {
|
|
if (!BREEDING_RECIPES[race] && need.total > 0) {
|
|
materials.push({ race, m: need.m, f: need.f });
|
|
}
|
|
}
|
|
|
|
steps.reverse();
|
|
return { materials, steps };
|
|
}
|
|
}
|