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:
@@ -261,12 +261,29 @@
|
||||
<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">
|
||||
<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>
|
||||
<template x-if="modalDetails">
|
||||
<span class="badge-run" :class="modalDetails.totalProfit >= 0 ? 'badge-profit' : 'badge-loss'"
|
||||
x-text="modalDetails.totalProfit >= 0 ? 'Rentable' : 'Con pérdida'"></span>
|
||||
</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>
|
||||
<p x-show="modalDetails" class="text-xs text-mono text-muted"
|
||||
x-text="modalDetails ? fmtDateTime(modalDetails.createdAt) : ''"></p>
|
||||
@@ -283,33 +300,38 @@
|
||||
<div class="grid-4 mb-6" style="gap:0.5rem">
|
||||
<div class="metric-card">
|
||||
<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 class="metric-card">
|
||||
<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 class="metric-card">
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ganancia</p>
|
||||
<p class="text-lg text-mono text-bold"
|
||||
:class="modalDetails.totalProfit > 0 ? 'text-success' : modalDetails.totalProfit < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(modalDetails.totalProfit)"></p>
|
||||
:class="displayTotals().totalProfit > 0 ? 'text-success' : displayTotals().totalProfit < 0 ? 'text-error' : ''"
|
||||
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 class="metric-card">
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Margen</p>
|
||||
<p class="text-lg text-mono text-bold"
|
||||
:class="marginPct(modalDetails) > 0 ? 'text-success' : marginPct(modalDetails) < 0 ? 'text-error' : ''"
|
||||
x-text="fmtPct(marginPct(modalDetails))"></p>
|
||||
:class="marginPctCurrent() > 0 ? 'text-success' : marginPctCurrent() < 0 ? 'text-error' : ''"
|
||||
x-text="fmtPct(marginPctCurrent())"></p>
|
||||
</div>
|
||||
<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-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 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-md text-mono text-bold"
|
||||
:class="profitPerShot(modalDetails) > 0 ? 'text-success' : profitPerShot(modalDetails) < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(profitPerShot(modalDetails))"></p>
|
||||
:class="profitPerShotCurrent() > 0 ? 'text-success' : profitPerShotCurrent() < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(profitPerShotCurrent())"></p>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Crafteos</p>
|
||||
@@ -317,7 +339,7 @@
|
||||
</div>
|
||||
<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-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>
|
||||
|
||||
@@ -339,12 +361,12 @@
|
||||
</div>
|
||||
<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-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>
|
||||
</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-segment-cristales" :style="'width:' + costCristalesPct() + '%'"></div>
|
||||
<div class="cost-split-segment-ore" :style="'width:' + costOrePct() + '%'"></div>
|
||||
@@ -429,7 +451,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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' : ''">
|
||||
<td class="text-bold" x-text="item.tipo"></td>
|
||||
<td class="text-center">
|
||||
@@ -487,6 +509,7 @@
|
||||
modalDetails: null,
|
||||
modalLoading: false,
|
||||
modalError: null,
|
||||
modalMode: 'snapshot',
|
||||
sort: { field: 'createdAt', dir: 'desc' },
|
||||
|
||||
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() {
|
||||
const field = this.sort.field;
|
||||
const dir = this.sort.dir === 'asc' ? 1 : -1;
|
||||
@@ -657,39 +745,53 @@
|
||||
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() {
|
||||
if (!this.modalDetails?.items) return 0;
|
||||
return this.modalDetails.items.reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
|
||||
return this.displayItems().reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
|
||||
},
|
||||
|
||||
costCristalesTotal() {
|
||||
if (!this.modalDetails?.items) return 0;
|
||||
return this.modalDetails.items.reduce((s, i) => s + (i.costoCristales || 0), 0);
|
||||
return this.displayItems().reduce((s, i) => s + (i.costoCristales || 0), 0);
|
||||
},
|
||||
|
||||
costOreTotal() {
|
||||
if (!this.modalDetails?.items) return 0;
|
||||
return this.modalDetails.items.reduce((s, i) => s + (i.costoOre || 0), 0);
|
||||
return this.displayItems().reduce((s, i) => s + (i.costoOre || 0), 0);
|
||||
},
|
||||
|
||||
costDwarvenTotal() {
|
||||
if (!this.modalDetails?.items) return 0;
|
||||
return this.modalDetails.items.reduce((s, i) => s + (i.costoDwarven || 0), 0);
|
||||
return this.displayItems().reduce((s, i) => s + (i.costoDwarven || 0), 0);
|
||||
},
|
||||
|
||||
costCristalesPct() {
|
||||
const total = this.modalDetails?.totalCost || 0;
|
||||
return total > 0 ? Math.round((this.costCristalesTotal() * 1000) / total) / 10 : 0;
|
||||
const t = this.displayTotals();
|
||||
return t.totalCost > 0 ? Math.round((t.costCristales * 1000) / t.totalCost) / 10 : 0;
|
||||
},
|
||||
|
||||
costOrePct() {
|
||||
const total = this.modalDetails?.totalCost || 0;
|
||||
return total > 0 ? Math.round((this.costOreTotal() * 1000) / total) / 10 : 0;
|
||||
const t = this.displayTotals();
|
||||
return t.totalCost > 0 ? Math.round((t.costOre * 1000) / t.totalCost) / 10 : 0;
|
||||
},
|
||||
|
||||
costDwarvenPct() {
|
||||
const total = this.modalDetails?.totalCost || 0;
|
||||
return total > 0 ? Math.round((this.costDwarvenTotal() * 1000) / total) / 10 : 0;
|
||||
const t = this.displayTotals();
|
||||
return t.totalCost > 0 ? Math.round((t.costDwarven * 1000) / t.totalCost) / 10 : 0;
|
||||
},
|
||||
|
||||
snapshotCristalesUsed() {
|
||||
|
||||
Reference in New Issue
Block a user