- saveRun(): filter items where cristalesDisponibles > 0 before persisting - allItems(): show only saved items in modal (no padding with zeros)
This commit is contained in:
@@ -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() {
|
||||
if (!this.modalDetails) return [];
|
||||
const tipos = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'];
|
||||
const grados = ['D', 'C', 'B', 'A', 'S'];
|
||||
const items = [];
|
||||
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;
|
||||
return this.modalDetails.items.map(item => ({
|
||||
...item,
|
||||
key: item.tipo + '-' + item.grado
|
||||
}));
|
||||
},
|
||||
|
||||
get sortedRuns() {
|
||||
|
||||
Reference in New Issue
Block a user