feat(historial): recalcular run con precios actuales
CI / Build Native (push) Successful in 6m54s

H4: en el modal de detalle de un run del historial, agregar un botón
que recalcula ganancia, costo y venta usando los precios actuales de
insumos en lugar del snapshot guardado.

Cambios:
- Nuevo campo modalMode ('snapshot' | 'current') en historialSection.
- displayItems()/recomputeItem(): si modalMode='current', recalcula
  cada item multiplicando las cantidades guardadas (cristalesUsados,
  oreNecesario, crafteosPosibles, shotsObtenidos) por los precios
  actuales de insumos. Las formulas del snapshot no se rederivan — solo
  se cambian los precios.
- displayTotals(): agrega totales en el modo activo.
- recomputeDelta(): snapshot_profit - current_profit para indicador.
- Refactor de costCristalesTotal/Ore/Dwarven/Pct + totalCrafteos +
  marginPct/costPerShot/profitPerShot para usar displayItems/displayTotals
  en lugar de modalDetails directo.
- Nuevos marginPctCurrent/costPerShotCurrent/profitPerShotCurrent.
- Modal totals cards + tabla de items leen de displayTotals() /
  displayItems().
- Toggle button 'Recalcular con precios actuales' ↔ 'Volver al snapshot'
  en el header del modal.
- Badge amarillo 'Precios actuales' cuando modalMode='current'.
- Indicador de delta en la card de Ganancia (↑ verde si ganás más,
  ↓ rojo si perdés más), formateado como '↑ X.XXX vs snapshot'.

Verificado en navegador: cambiar venta en Insumos → en el modal del
historial, el toggle actualiza todos los valores (totales, desglose,
distribución, items) usando precios nuevos. La matemática es consistente
con la calculadora principal.
This commit is contained in:
2026-08-21 20:07:41 -04:00
parent e0ac9e844f
commit c109a59d3d
@@ -261,12 +261,29 @@
<div class="modal-content modal-lg" @click.stop> <div class="modal-content modal-lg" @click.stop>
<header class="flex-row justify-between" style="padding:1rem 1.5rem;border-bottom:1px solid var(--pico-muted-border-color);align-items:flex-start"> <header class="flex-row justify-between" style="padding:1rem 1.5rem;border-bottom:1px solid var(--pico-muted-border-color);align-items:flex-start">
<div> <div>
<div class="flex-row gap-2" style="align-items:center;margin-bottom:0.25rem"> <div class="flex-row gap-2" style="align-items:center;margin-bottom:0.25rem;flex-wrap:wrap">
<h2 style="font-size:1rem;margin:0" x-text="modalDetails?.label || 'Detalle de producción'"></h2> <h2 style="font-size:1rem;margin:0" x-text="modalDetails?.label || 'Detalle de producción'"></h2>
<template x-if="modalDetails"> <template x-if="modalDetails">
<span class="badge-run" :class="modalDetails.totalProfit >= 0 ? 'badge-profit' : 'badge-loss'" <span class="badge-run" :class="modalDetails.totalProfit >= 0 ? 'badge-profit' : 'badge-loss'"
x-text="modalDetails.totalProfit >= 0 ? 'Rentable' : 'Con pérdida'"></span> x-text="modalDetails.totalProfit >= 0 ? 'Rentable' : 'Con pérdida'"></span>
</template> </template>
<span x-show="modalMode === 'current'" class="badge-warning">Precios actuales</span>
<button type="button"
x-show="modalMode === 'snapshot' && modalDetails"
@click="modalMode = 'current'"
class="outline secondary"
style="font-size:0.7rem;padding:0.2rem 0.5rem;margin-left:auto"
title="Recalcular ganancia, costo y venta usando los precios actuales en lugar del snapshot">
🔄 Recalcular con precios actuales
</button>
<button type="button"
x-show="modalMode === 'current' && modalDetails"
@click="modalMode = 'snapshot'"
class="outline secondary"
style="font-size:0.7rem;padding:0.2rem 0.5rem;margin-left:auto"
title="Volver a los valores del snapshot original">
↩ Volver al snapshot
</button>
</div> </div>
<p x-show="modalDetails" class="text-xs text-mono text-muted" <p x-show="modalDetails" class="text-xs text-mono text-muted"
x-text="modalDetails ? fmtDateTime(modalDetails.createdAt) : ''"></p> x-text="modalDetails ? fmtDateTime(modalDetails.createdAt) : ''"></p>
@@ -283,33 +300,38 @@
<div class="grid-4 mb-6" style="gap:0.5rem"> <div class="grid-4 mb-6" style="gap:0.5rem">
<div class="metric-card"> <div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo total</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(modalDetails.totalCost)"></p> <p class="text-lg text-mono text-bold" x-text="fmtAdena(displayTotals().totalCost)"></p>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Venta total</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Venta total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(modalDetails.totalSale)"></p> <p class="text-lg text-mono text-bold" x-text="fmtAdena(displayTotals().totalSale)"></p>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ganancia</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ganancia</p>
<p class="text-lg text-mono text-bold" <p class="text-lg text-mono text-bold"
:class="modalDetails.totalProfit > 0 ? 'text-success' : modalDetails.totalProfit < 0 ? 'text-error' : ''" :class="displayTotals().totalProfit > 0 ? 'text-success' : displayTotals().totalProfit < 0 ? 'text-error' : ''"
x-text="fmtAdena(modalDetails.totalProfit)"></p> x-text="fmtAdena(displayTotals().totalProfit)"></p>
<p x-show="modalMode === 'current' && recomputeDelta() !== null && recomputeDelta() !== 0"
class="text-xs"
style="margin-top:0.15rem"
:class="recomputeDelta() > 0 ? 'text-error' : 'text-success'"
x-text="(recomputeDelta() > 0 ? '↓ ' : '↑ ') + fmtAdena(Math.abs(recomputeDelta())) + ' vs snapshot'"></p>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Margen</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Margen</p>
<p class="text-lg text-mono text-bold" <p class="text-lg text-mono text-bold"
:class="marginPct(modalDetails) > 0 ? 'text-success' : marginPct(modalDetails) < 0 ? 'text-error' : ''" :class="marginPctCurrent() > 0 ? 'text-success' : marginPctCurrent() < 0 ? 'text-error' : ''"
x-text="fmtPct(marginPct(modalDetails))"></p> x-text="fmtPct(marginPctCurrent())"></p>
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo/shot</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo/shot</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(costPerShot(modalDetails))"></p> <p class="text-md text-mono text-bold" x-text="fmtAdena(costPerShotCurrent())"></p>
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Gan./shot</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Gan./shot</p>
<p class="text-md text-mono text-bold" <p class="text-md text-mono text-bold"
:class="profitPerShot(modalDetails) > 0 ? 'text-success' : profitPerShot(modalDetails) < 0 ? 'text-error' : ''" :class="profitPerShotCurrent() > 0 ? 'text-success' : profitPerShotCurrent() < 0 ? 'text-error' : ''"
x-text="fmtAdena(profitPerShot(modalDetails))"></p> x-text="fmtAdena(profitPerShotCurrent())"></p>
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Crafteos</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Crafteos</p>
@@ -317,7 +339,7 @@
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ore total</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ore total</p>
<p class="text-md text-mono text-bold" x-text="modalDetails.totalOreUsed"></p> <p class="text-md text-mono text-bold" x-text="displayItems().reduce((s, i) => s + (i.oreNecesario || 0), 0)"></p>
</div> </div>
</div> </div>
@@ -339,12 +361,12 @@
</div> </div>
<div class="metric-card metric-card-sm"> <div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo total</p> <p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo total</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(modalDetails.totalCost)"></p> <p class="text-md text-mono text-bold" x-text="fmtAdena(displayTotals().totalCost)"></p>
<p class="text-xs text-muted">esta corrida</p> <p class="text-xs text-muted">esta corrida</p>
</div> </div>
</div> </div>
<div class="cost-split-section mb-6" x-show="modalDetails.totalCost > 0"> <div class="cost-split-section mb-6" x-show="displayTotals().totalCost > 0">
<div class="cost-split-bar" style="width:100%;max-width:32rem;height:1rem"> <div class="cost-split-bar" style="width:100%;max-width:32rem;height:1rem">
<div class="cost-split-segment-cristales" :style="'width:' + costCristalesPct() + '%'"></div> <div class="cost-split-segment-cristales" :style="'width:' + costCristalesPct() + '%'"></div>
<div class="cost-split-segment-ore" :style="'width:' + costOrePct() + '%'"></div> <div class="cost-split-segment-ore" :style="'width:' + costOrePct() + '%'"></div>
@@ -429,7 +451,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template x-for="item in allItems()" :key="item.key"> <template x-for="item in displayItems()" :key="item.key">
<tr :style="item.ganancia < 0 ? 'background:#fff5f5' : ''"> <tr :style="item.ganancia < 0 ? 'background:#fff5f5' : ''">
<td class="text-bold" x-text="item.tipo"></td> <td class="text-bold" x-text="item.tipo"></td>
<td class="text-center"> <td class="text-center">
@@ -487,6 +509,7 @@
modalDetails: null, modalDetails: null,
modalLoading: false, modalLoading: false,
modalError: null, modalError: null,
modalMode: 'snapshot',
sort: { field: 'createdAt', dir: 'desc' }, sort: { field: 'createdAt', dir: 'desc' },
async init() { async init() {
@@ -549,6 +572,71 @@
})); }));
}, },
displayItems() {
if (this.modalMode === 'snapshot' || !this.modalDetails) {
return this.allItems();
}
return this.allItems().map(item => this.recomputeItem(item));
},
recomputeItem(item) {
const insumos = this.store()?.state?.insumos;
if (!insumos) return item;
const precioCristal = insumos.cristales?.[item.grado] || 0;
const orePrice = item.tipo === 'Soulshot'
? (insumos.soulOre || 0)
: (insumos.spiritOre || 0);
const ventaPrice = insumos.venta?.[item.tipo]?.[item.grado] || 0;
const dwarvenFee = insumos.dwarvenFeePerCraft || 0;
const costoCristales = item.cristalesUsados * precioCristal;
const costoOre = item.oreNecesario * orePrice;
const costoDwarven = item.crafteosPosibles * dwarvenFee;
const costoTotal = costoCristales + costoOre + costoDwarven;
const valorVenta = item.shotsObtenidos * ventaPrice;
const ganancia = valorVenta - costoTotal;
return {
...item,
costoCristales,
costoOre,
costoDwarven,
costoTotal,
valorVenta,
ganancia,
};
},
displayTotals() {
const items = this.displayItems();
let costo = 0, venta = 0, ganancia = 0;
let costCristales = 0, costOre = 0, costDwarven = 0;
let shots = 0;
for (const item of items) {
costo += item.costoTotal || 0;
venta += item.valorVenta || 0;
ganancia += item.ganancia || 0;
costCristales += item.costoCristales || 0;
costOre += item.costoOre || 0;
costDwarven += item.costoDwarven || 0;
shots += item.shotsObtenidos || 0;
}
return {
totalCost: costo,
totalSale: venta,
totalProfit: ganancia,
totalShots: shots,
costCristales,
costOre,
costDwarven,
};
},
recomputeDelta() {
if (this.modalMode !== 'current' || !this.modalDetails) return null;
return this.modalDetails.totalProfit - this.displayTotals().totalProfit;
},
get sortedRuns() { get sortedRuns() {
const field = this.sort.field; const field = this.sort.field;
const dir = this.sort.dir === 'asc' ? 1 : -1; const dir = this.sort.dir === 'asc' ? 1 : -1;
@@ -657,39 +745,53 @@
return Math.round(details.totalProfit / details.totalShots); return Math.round(details.totalProfit / details.totalShots);
}, },
marginPctCurrent() {
const t = this.displayTotals();
if (!t.totalCost) return 0;
return Math.round((t.totalProfit / t.totalCost) * 1000) / 10;
},
costPerShotCurrent() {
const t = this.displayTotals();
if (!t.totalShots) return 0;
return Math.round(t.totalCost / t.totalShots);
},
profitPerShotCurrent() {
const t = this.displayTotals();
if (!t.totalShots) return 0;
return Math.round(t.totalProfit / t.totalShots);
},
totalCrafteos() { totalCrafteos() {
if (!this.modalDetails?.items) return 0; return this.displayItems().reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
return this.modalDetails.items.reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
}, },
costCristalesTotal() { costCristalesTotal() {
if (!this.modalDetails?.items) return 0; return this.displayItems().reduce((s, i) => s + (i.costoCristales || 0), 0);
return this.modalDetails.items.reduce((s, i) => s + (i.costoCristales || 0), 0);
}, },
costOreTotal() { costOreTotal() {
if (!this.modalDetails?.items) return 0; return this.displayItems().reduce((s, i) => s + (i.costoOre || 0), 0);
return this.modalDetails.items.reduce((s, i) => s + (i.costoOre || 0), 0);
}, },
costDwarvenTotal() { costDwarvenTotal() {
if (!this.modalDetails?.items) return 0; return this.displayItems().reduce((s, i) => s + (i.costoDwarven || 0), 0);
return this.modalDetails.items.reduce((s, i) => s + (i.costoDwarven || 0), 0);
}, },
costCristalesPct() { costCristalesPct() {
const total = this.modalDetails?.totalCost || 0; const t = this.displayTotals();
return total > 0 ? Math.round((this.costCristalesTotal() * 1000) / total) / 10 : 0; return t.totalCost > 0 ? Math.round((t.costCristales * 1000) / t.totalCost) / 10 : 0;
}, },
costOrePct() { costOrePct() {
const total = this.modalDetails?.totalCost || 0; const t = this.displayTotals();
return total > 0 ? Math.round((this.costOreTotal() * 1000) / total) / 10 : 0; return t.totalCost > 0 ? Math.round((t.costOre * 1000) / t.totalCost) / 10 : 0;
}, },
costDwarvenPct() { costDwarvenPct() {
const total = this.modalDetails?.totalCost || 0; const t = this.displayTotals();
return total > 0 ? Math.round((this.costDwarvenTotal() * 1000) / total) / 10 : 0; return t.totalCost > 0 ? Math.round((t.costDwarven * 1000) / t.totalCost) / 10 : 0;
}, },
snapshotCristalesUsed() { snapshotCristalesUsed() {