fix: only save items with cristalesDisponibles > 0
CI / Build Native (push) Failing after 43s

- saveRun(): filter items where cristalesDisponibles > 0 before persisting
- allItems(): show only saved items in modal (no padding with zeros)
This commit is contained in:
2026-08-15 23:19:24 -04:00
parent e913b5ceb0
commit 05db929da1
2 changed files with 284 additions and 18 deletions
@@ -0,0 +1,280 @@
<!doctype html>
<html lang="es" data-theme="light">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Calculadora — Calculadora de Craft de Shots</title>
<link rel="stylesheet" href="/static/css/pico.min.css"/>
<link rel="stylesheet" href="/static/css/app.css"/>
</head>
<body x-data="appShell()">
{#include partials/header.html /}
{#include partials/tab-bar.html /}
<main class="container" x-data="calculadoraSection()">
<div class="bg-warning">
<p>
Ingresa solo los <strong>cristales disponibles</strong> por grado en cada tabla. La ore
necesaria, los crafteos, los shots producidos, el costo y la ganancia se calculan en
tiempo real a partir de los precios de la pestana <em>Insumos</em> y las recetas de la
pestana <em>Formulas</em>.
</p>
</div>
<article>
<div class="flex-row">
<label class="flex-1">
<span class="text-sm text-muted mb-2">Label (opcional)</span>
<input type="text"
x-model="runLabel"
maxlength="100"
placeholder="p.ej. Sesion lunes, crafteo nocturno..."/>
</label>
<div class="flex-row" style="align-items:flex-end">
<button type="button"
@click="saveRun()"
:disabled="!canSave()"
:title="totalesGlobal().cristalesUsados === 0 ? 'Ingresa cristales disponibles para poder guardar' : 'Guardar esta produccion en el historial'"
class="primary">
<span x-show="saveStatus !== 'saving'">Guardar produccion</span>
<span x-show="saveStatus === 'saving'">Guardando…</span>
</button>
</div>
</div>
<div x-show="saveStatus === 'saved'" class="success-msg mt-2">Produccion guardada. La podes ver en la pestana Historial.</div>
<div x-show="saveStatus === 'error'" class="error-msg mt-2" x-text="saveError || 'Error al guardar'"></div>
</article>
<template x-for="t in tablas()" :key="t.tipo">
<article>
<header>
<h2 x-text="t.tipo"></h2>
<p class="text-sm text-muted">Crafteo de <span x-text="t.tipo"></span>. Ingresa solo los cristales disponibles por grado.</p>
</header>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Cristales disp.</th>
<th class="cell-label text-right">Cristales usados</th>
<th class="cell-label text-right" x-text="t.tipo === 'Soulshot' ? 'Soul Ore nec.' : 'Spirit Ore nec.'"></th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
<template x-for="f in t.formulas" :key="f.id">
<tr :class="rowClass(t.tipo, f.grado)">
<td class="text-center">
<span class="grade-badge" x-text="f.grado"></span>
</td>
<td>
<input type="number" min="0" step="1"
x-model.number="store().state.disponibles[f.tipo][f.grado]"
@input="store().scheduleSave()"/>
</td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.cristalesUsados)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.oreNecesario)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.crafteosPosibles)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.shotsObtenidos)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.costoTotal)"></td>
<td class="text-mono text-right" x-text="fmtAdena(calculo(f.tipo, f.grado)?.valorVenta)"></td>
<td class="text-mono text-right text-bold"
:class="gananciaClass(calculo(f.tipo, f.grado)?.ganancia)"
x-text="fmtAdena(calculo(f.tipo, f.grado)?.ganancia)"></td>
</tr>
</template>
</tbody>
<tfoot>
<tr>
<td class="cell-label text-center">Σ</td>
<td class="cell-label text-right text-muted"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.cristalesUsados)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.oreNecesario)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.crafteosPosibles)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.shotsObtenidos)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.costoTotal)"></td>
<td class="cell-label text-right text-mono" x-text="fmtAdena(t.totales.valorVenta)"></td>
<td class="cell-label text-right text-mono" :class="gananciaClass(t.totales.ganancia)"
x-text="fmtAdena(t.totales.ganancia)"></td>
</tr>
</tfoot>
</table>
</div>
<template x-if="hasWarning(t)">
<p class="text-sm" style="color:#92400e;background:#fef3c7;border:1px solid #fcd34d;padding:0.5rem 0.75rem;border-radius:4px;margin-top:0.5rem">
⚠️ Una o mas filas tienen cristales requeridos en 0. Completa la receta en la pestana Formulas.
</p>
</template>
</article>
</template>
<article>
<header><h2>Totales globales</h2></header>
<div class="grid-8 text-center">
<div>
<p class="text-xs text-muted mb-2">Cristales usados</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().cristalesUsados)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Ore necesaria</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().oreNecesario)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Crafteos</p>
<p class="text-lg text-mono text-bold" x-text="totalesGlobal().crafteosPosibles"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Shots obtenidos</p>
<p class="text-lg text-mono text-bold" x-text="totalesGlobal().shotsObtenidos"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Costo total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().costoTotal)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Valor venta</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(totalesGlobal().valorVenta)"></p>
</div>
<div>
<p class="text-xs text-muted mb-2">Ganancia</p>
<p class="text-lg text-mono text-bold"
:class="gananciaClass(totalesGlobal().ganancia)"
x-text="fmtAdena(totalesGlobal().ganancia)"></p>
</div>
</div>
</article>
</main>
<footer class="container">
<p class="text-center text-xs text-muted">
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
</p>
</footer>
<script src="/static/js/defaults.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/calc.js"></script>
<script src="/static/js/format.js"></script>
<script src="/static/js/app.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('calculadoraSection', () => ({
store() { return Alpine.store('app'); },
runLabel: '',
saveStatus: 'idle',
saveError: null,
calculo(tipo, grado) {
const s = this.store().state;
if (!s) return null;
const f = s.formulas.find(x => x.tipo === tipo && x.grado === grado);
if (!f) return null;
return window.calc.calcularFila(f, s.disponibles[tipo]?.[grado] || 0, s.insumos);
},
tablas() {
const s = this.store().state;
if (!s) return [];
return ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'].map(tipo => {
const formulasTipo = s.formulas.filter(f => f.tipo === tipo);
const calculos = formulasTipo.map(f =>
window.calc.calcularFila(f, s.disponibles[tipo]?.[f.grado] || 0, s.insumos)
);
const totales = window.calc.sumarTotales(calculos);
return { tipo, formulas: formulasTipo, calculos, totales };
});
},
totalesGlobal() {
return window.calc.sumarTotales(this.tablas().flatMap(t => t.calculos));
},
canSave() {
return this.totalesGlobal().cristalesUsados > 0 && this.saveStatus !== 'saving';
},
rowClass(tipo, grado) {
const c = this.calculo(tipo, grado);
return c?.warning ? 'bg-warning' : '';
},
hasWarning(tabla) {
return tabla.formulas.some(f => {
const c = this.calculo(f.tipo, f.grado);
return c?.warning || f.cristalesReq === 0;
});
},
gananciaClass(v) {
if (v > 0) return 'text-success';
if (v < 0) return 'text-error';
return '';
},
fmtAdena(n) {
if (n == null) return '—';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toLocaleString();
},
async saveRun() {
if (!this.canSave()) return;
const tablas = this.tablas();
const tg = this.totalesGlobal();
const s = this.store().state;
const items = tablas.flatMap(t =>
t.formulas.map((f, i) => {
const c = t.calculos[i];
return {
tipo: t.tipo, grado: f.grado,
cristalesDisponibles: c.cristalesDisponibles,
cristalesUsados: c.cristalesUsados,
oreNecesario: c.oreNecesario,
crafteosPosibles: c.crafteosPosibles,
shotsObtenidos: c.shotsObtenidos,
costoTotal: c.costoTotal,
valorVenta: c.valorVenta,
ganancia: c.ganancia,
};
}).filter(item => item.cristalesDisponibles > 0)
);
this.saveStatus = 'saving';
this.saveError = null;
try {
await window.api.saveHistoryRun({
label: this.runLabel.trim() || null,
totalCost: tg.costoTotal,
totalSale: tg.valorVenta,
totalProfit: tg.ganancia,
totalShots: tg.shotsObtenidos,
totalCristalesUsed: tg.cristalesUsados,
totalOreUsed: tg.oreNecesario,
items,
snapshot: { insumos: s.insumos, formulas: s.formulas },
});
this.saveStatus = 'saved';
this.runLabel = '';
setTimeout(() => { this.saveStatus = 'idle'; }, 3000);
} catch (err) {
this.saveStatus = 'error';
this.saveError = err.message || 'Error al guardar';
}
},
}));
});
</script>
</body>
</html>
@@ -475,24 +475,10 @@
allItems() { allItems() {
if (!this.modalDetails) return []; if (!this.modalDetails) return [];
const tipos = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot']; return this.modalDetails.items.map(item => ({
const grados = ['D', 'C', 'B', 'A', 'S']; ...item,
const items = []; key: item.tipo + '-' + item.grado
for (const tipo of tipos) { }));
for (const grado of grados) {
const item = this.modalDetails.items.find(
i => i.tipo === tipo && i.grado === grado
);
if (item) {
items.push({ ...item, key: tipo + '-' + grado });
} else {
items.push({ tipo, grado, key: tipo + '-' + grado,
ganancia: 0, costoTotal: 0, valorVenta: 0, shotsObtenidos: 0,
crafteosPosibles: 0, oreNecesario: 0, cristalesUsados: 0, cristalesDisponibles: 0 });
}
}
}
return items;
}, },
get sortedRuns() { get sortedRuns() {