feat(historial): comprehensive enrichment of history page
CI / Build Native (push) Failing after 1m21s
CI / Build Native (push) Failing after 1m21s
Backend: - RunSummary: add profitMarginPct, costPerShot, profitPerShot, itemCount, tiposUsados, wasProfitable - HistoryStats: add avgProfitMarginPct, avgCostPerShot, avgProfitPerShot, totalOreUsed, totalItems, tiposBreakdown - HistoryService: compute all new fields, parse items for best/worst run Frontend (historial.html): - 8 stats cards instead of 5: +Margen promedio, +Total invertido, +Shots, +Costo/shot - SVG sparkline showing last 10 runs profit trend - Ganancia por tipo breakdown with horizontal bars - Table: sortable columns (click headers), new cols: Margen%, Costo/shot, Items chips, delta vs avg - Sortable by: Fecha, Label, Costo, Venta, Ganancia, Margen - Modal: badge Profit/Loss, 8 metric cards, snapshot of prices at save time, comparison vs current prices - Export buttons: CSV (resumen) + JSON (completo con items y snapshot) Format.js: - Add fmtPercent() with es-CL locale CSS: - Add sparkline, tipos-breakdown, sortable columns, delta badges, metric cards, diff badges, modal-lg
This commit is contained in:
@@ -0,0 +1,691 @@
|
||||
<!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>Historial — 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="historialSection()">
|
||||
|
||||
<div x-show="loading && !stats" class="text-center p-6">
|
||||
<p class="text-sm text-muted">Cargando historial…</p>
|
||||
</div>
|
||||
|
||||
<template x-if="stats && stats.totalRuns > 0">
|
||||
<div>
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Total ganado</p>
|
||||
<p class="text-xl text-mono text-bold"
|
||||
:class="stats.totalProfit > 0 ? 'text-success' : stats.totalProfit < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(stats.totalProfit)"></p>
|
||||
<p class="text-xs text-muted" x-text="stats.totalRuns + ' corrida' + (stats.totalRuns === 1 ? '' : 's')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Promedio</p>
|
||||
<p class="text-xl text-mono text-bold"
|
||||
:class="stats.avgProfit > 0 ? 'text-success' : stats.avgProfit < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(stats.avgProfit)"></p>
|
||||
<p class="text-xs text-muted">por corrida</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Mejor corrida</p>
|
||||
<p class="text-xl text-mono text-bold text-success"
|
||||
x-text="stats.bestRun ? fmtAdena(stats.bestRun.totalProfit) : '—'"></p>
|
||||
<p class="text-xs text-muted truncate" x-text="stats.bestRun?.label || (stats.bestRun?.createdAt ? fmtDate(stats.bestRun.createdAt) : '')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Peor corrida</p>
|
||||
<p class="text-xl text-mono text-bold"
|
||||
:class="stats.worstRun && stats.worstRun.totalProfit < 0 ? 'text-error' : ''"
|
||||
x-text="stats.worstRun ? fmtAdena(stats.worstRun.totalProfit) : '—'"></p>
|
||||
<p class="text-xs text-muted truncate" x-text="stats.worstRun?.label || (stats.worstRun?.createdAt ? fmtDate(stats.worstRun.createdAt) : '')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Margen promedio</p>
|
||||
<p class="text-xl text-mono text-bold"
|
||||
:class="stats.avgProfitMarginPct > 0 ? 'text-success' : stats.avgProfitMarginPct < 0 ? 'text-error' : ''"
|
||||
x-text="fmtPct(stats.avgProfitMarginPct)"></p>
|
||||
<p class="text-xs text-muted">rentabilidad</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Total invertido</p>
|
||||
<p class="text-xl text-mono text-bold" x-text="fmtAdena(stats.totalCost)"></p>
|
||||
<p class="text-xs text-muted">costo total</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Shots</p>
|
||||
<p class="text-xl text-mono text-bold" x-text="fmtNumber(stats.totalShots)"></p>
|
||||
<p class="text-xs text-muted">producidos</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo por shot</p>
|
||||
<p class="text-xl text-mono text-bold" x-text="fmtAdena(stats.avgCostPerShot)"></p>
|
||||
<p class="text-xs text-muted">promedio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sparkline-container" x-show="runs.length > 0">
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">Tendencia reciente</p>
|
||||
<div class="sparkline">
|
||||
<template x-for="(bar, idx) in sparklineBars" :key="idx">
|
||||
<div class="sparkline-bar-wrap" :title="bar.label + ': ' + fmtAdena(bar.profit)">
|
||||
<div class="sparkline-bar"
|
||||
:class="bar.profit >= 0 ? 'bar-positive' : 'bar-negative'"
|
||||
:style="'height:' + bar.heightPct + '%'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="sparkline-labels">
|
||||
<span class="text-xs text-muted">más antigua</span>
|
||||
<span class="text-xs text-muted">más reciente</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="stats.tiposBreakdown && Object.keys(stats.tiposBreakdown).length > 0">
|
||||
<div class="tipos-breakdown">
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">Ganancia por tipo</p>
|
||||
<div class="tipos-bars">
|
||||
<template x-for="[tipo, ganancia] in Object.entries(stats.tiposBreakdown)" :key="tipo">
|
||||
<div class="tipo-bar-row">
|
||||
<span class="text-sm text-bold" x-text="tipo"></span>
|
||||
<div class="tipo-bar-track">
|
||||
<div class="tipo-bar-fill"
|
||||
:class="ganancia >= 0 ? 'fill-positive' : 'fill-negative'"
|
||||
:style="'width:' + Math.min(100, Math.abs(ganancia) / (Math.max(...Object.values(stats.tiposBreakdown).map(v => Math.abs(v))) || 1) * 100) + '%'"></div>
|
||||
</div>
|
||||
<span class="text-mono text-sm" :class="ganancia >= 0 ? 'text-success' : 'text-error'"
|
||||
x-text="fmtAdena(ganancia)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="stats && stats.totalRuns === 0">
|
||||
<div class="bg-warning mb-4">
|
||||
<p class="text-sm">
|
||||
Aún no hay producciones guardadas. Ve a la pestaña <strong>Calculadora</strong>,
|
||||
configura los cristales disponibles y presiona <strong>Guardar producción</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section>
|
||||
<header class="flex-row justify-between mb-3" style="align-items:center">
|
||||
<h2 class="text-sm text-bold">Corridas guardadas</h2>
|
||||
<div class="flex-row gap-2" style="align-items:center">
|
||||
<span x-show="runs.length > 0" class="text-xs text-muted"
|
||||
x-text="runs.length + ' en total · click en fila para ver detalle'"></span>
|
||||
<template x-if="runs.length > 0">
|
||||
<div class="flex-row gap-1">
|
||||
<button type="button" @click="exportCSV()" class="outline contrast" style="font-size:0.7rem;padding:0.2rem 0.5rem" title="Exportar a CSV">
|
||||
CSV
|
||||
</button>
|
||||
<button type="button" @click="exportJSON()" class="outline contrast" style="font-size:0.7rem;padding:0.2rem 0.5rem" title="Exportar a JSON">
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div x-show="runs.length === 0 && !loading" class="text-center p-6">
|
||||
<p class="text-sm text-muted">No hay corridas para mostrar.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="runs.length > 0" class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cell-label sortable" @click="sortBy('createdAt')">
|
||||
Fecha <span class="sort-icon" x-text="sortIcon('createdAt')"></span>
|
||||
</th>
|
||||
<th class="cell-label sortable" @click="sortBy('label')">
|
||||
Label <span class="sort-icon" x-text="sortIcon('label')"></span>
|
||||
</th>
|
||||
<th class="cell-label text-right sortable" @click="sortBy('totalCost')">
|
||||
Costo <span class="sort-icon" x-text="sortIcon('totalCost')"></span>
|
||||
</th>
|
||||
<th class="cell-label text-right sortable" @click="sortBy('totalSale')">
|
||||
Venta <span class="sort-icon" x-text="sortIcon('totalSale')"></span>
|
||||
</th>
|
||||
<th class="cell-label text-right sortable" @click="sortBy('totalProfit')">
|
||||
Ganancia <span class="sort-icon" x-text="sortIcon('totalProfit')"></span>
|
||||
</th>
|
||||
<th class="cell-label text-right sortable" @click="sortBy('profitMarginPct')">
|
||||
Margen <span class="sort-icon" x-text="sortIcon('profitMarginPct')"></span>
|
||||
</th>
|
||||
<th class="cell-label text-right">Costo/shot</th>
|
||||
<th class="cell-label text-right">Items</th>
|
||||
<th class="cell-label text-center">Accion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in sortedRuns" :key="r.id">
|
||||
<tr style="cursor:pointer" @click="openRun(r.id)">
|
||||
<td class="text-mono text-xs" x-text="fmtDateTime(r.createdAt)"></td>
|
||||
<td>
|
||||
<span x-text="r.label || '(sin label)'"></span>
|
||||
<template x-if="r.tiposUsados && r.tiposUsados.length > 0">
|
||||
<div class="flex-row gap-1 mt-1">
|
||||
<template x-for="tipo in r.tiposUsados" :key="tipo">
|
||||
<span class="tipo-chip" x-text="tipo.replace('Spiritshot','SS').replace('Blessed Spiritshot','BSS').replace('Soulshot','SS')"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="text-mono text-right" x-text="fmtAdena(r.totalCost)"></td>
|
||||
<td class="text-mono text-right" x-text="fmtAdena(r.totalSale)"></td>
|
||||
<td class="text-mono text-right">
|
||||
<span class="text-bold"
|
||||
:class="r.totalProfit > 0 ? 'text-success' : r.totalProfit < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(r.totalProfit)"></span>
|
||||
<span class="delta-badge" :class="deltaClass(r.totalProfit)"
|
||||
x-show="deltaVsAvg(r.totalProfit) !== null"
|
||||
x-text="deltaIcon(r.totalProfit) + ' ' + fmtAdena(Math.abs(deltaVsAvg(r.totalProfit)))"></span>
|
||||
</td>
|
||||
<td class="text-mono text-right">
|
||||
<span :class="r.profitMarginPct > 0 ? 'text-success' : r.profitMarginPct < 0 ? 'text-error' : ''"
|
||||
x-text="fmtPct(r.profitMarginPct)"></span>
|
||||
</td>
|
||||
<td class="text-mono text-right text-muted" x-text="fmtAdena(r.costPerShot)"></td>
|
||||
<td class="text-mono text-right" x-text="r.itemCount"></td>
|
||||
<td class="text-center">
|
||||
<button type="button"
|
||||
@click.stop="deleteRun(r.id)"
|
||||
class="outline contrast"
|
||||
style="font-size:0.75rem;padding:0.25rem 0.5rem"
|
||||
title="Borrar corrida">
|
||||
Borrar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div x-show="modalRunId"
|
||||
x-cloak
|
||||
class="modal-backdrop"
|
||||
@click.self="closeModal()">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<p x-show="modalDetails" class="text-xs text-mono text-muted"
|
||||
x-text="modalDetails ? fmtDateTime(modalDetails.createdAt) : ''"></p>
|
||||
</div>
|
||||
<button type="button" @click="closeModal()" class="close" aria-label="Cerrar">×</button>
|
||||
</header>
|
||||
|
||||
<div style="padding:1.5rem;overflow-y:auto;max-height:80vh">
|
||||
<div x-show="modalLoading" class="text-sm text-muted">Cargando…</div>
|
||||
<div x-show="modalError" class="error-msg mb-4" x-text="modalError"></div>
|
||||
|
||||
<template x-if="modalDetails">
|
||||
<div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<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-md text-mono text-bold" x-text="totalCrafteos()"></p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="modalDetails.snapshot">
|
||||
<div class="snapshot-section">
|
||||
<h3 class="text-sm text-bold mb-3">
|
||||
Snapshot de precios
|
||||
<span class="text-xs text-muted" style="font-weight:normal"> (precios al momento de guardar)</span>
|
||||
</h3>
|
||||
|
||||
<div class="grid-3 mb-4" style="gap:0.75rem">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">Cristales</p>
|
||||
<div class="snapshot-grid">
|
||||
<template x-for="[grado, precio] in Object.entries(snapshotCristales())" :key="grado">
|
||||
<div class="snapshot-row">
|
||||
<span class="text-xs text-muted" x-text="'Cristal ' + grado"></span>
|
||||
<span class="text-mono text-xs" x-text="fmtAdena(precio)"></span>
|
||||
<template x-if="currentCristal(grado) !== null">
|
||||
<span class="diff-badge" :class="currentCristal(grado) > precio ? 'diff-up' : currentCristal(grado) < precio ? 'diff-down' : 'diff-same'"
|
||||
x-text="currentCristal(grado) !== precio ? (currentCristal(grado) > precio ? '+' : '') + pctDiff(currentCristal(grado), precio) : '=='"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">Ores</p>
|
||||
<div class="snapshot-grid">
|
||||
<div class="snapshot-row">
|
||||
<span class="text-xs text-muted">Soul Ore</span>
|
||||
<span class="text-mono text-xs" x-text="fmtAdena(snapshotOre('soulOre'))"></span>
|
||||
<template x-if="currentOre('soulOre') !== null">
|
||||
<span class="diff-badge" :class="currentOre('soulOre') > snapshotOre('soulOre') ? 'diff-up' : currentOre('soulOre') < snapshotOre('soulOre') ? 'diff-down' : 'diff-same'"
|
||||
x-text="currentOre('soulOre') !== snapshotOre('soulOre') ? (currentOre('soulOre') > snapshotOre('soulOre') ? '+' : '') + pctDiff(currentOre('soulOre'), snapshotOre('soulOre')) : '=='"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="snapshot-row">
|
||||
<span class="text-xs text-muted">Spirit Ore</span>
|
||||
<span class="text-mono text-xs" x-text="fmtAdena(snapshotOre('spiritOre'))"></span>
|
||||
<template x-if="currentOre('spiritOre') !== null">
|
||||
<span class="diff-badge" :class="currentOre('spiritOre') > snapshotOre('spiritOre') ? 'diff-up' : currentOre('spiritOre') < snapshotOre('spiritOre') ? 'diff-down' : 'diff-same'"
|
||||
x-text="currentOre('spiritOre') !== snapshotOre('spiritOre') ? (currentOre('spiritOre') > snapshotOre('spiritOre') ? '+' : '') + pctDiff(currentOre('spiritOre'), snapshotOre('spiritOre')) : '=='"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">Precios de venta</p>
|
||||
<div class="snapshot-grid" style="font-size:0.7rem">
|
||||
<template x-for="[key, precio] in Object.entries(snapshotVenta())" :key="key">
|
||||
<div class="snapshot-row">
|
||||
<span class="text-xs text-muted" x-text="key.replace('Soulshot','SS ').replace('Spiritshot','SS ').replace('Blessed Spiritshot','BSS ') + key.split('-')[1]"></span>
|
||||
<span class="text-mono text-xs" x-text="fmtAdena(precio)"></span>
|
||||
<template x-if="currentVenta(key) !== null">
|
||||
<span class="diff-badge" :class="currentVenta(key) > precio ? 'diff-up' : currentVenta(key) < precio ? 'diff-down' : 'diff-same'"
|
||||
x-text="currentVenta(key) !== precio ? (currentVenta(key) > precio ? '+' : '') + pctDiff(currentVenta(key), precio) : '=='"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h3 class="text-sm text-bold mb-2">Desglose por grado</h3>
|
||||
<div class="table-wrapper" style="border:1px solid var(--pico-muted-border-color);border-radius:4px;overflow:hidden">
|
||||
<table style="font-size:0.75rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cell-label text-left">Tipo</th>
|
||||
<th class="cell-label text-center">Grado</th>
|
||||
<th class="cell-label text-right">Cristales</th>
|
||||
<th class="cell-label text-right">Ore</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="item in allItems()" :key="item.key">
|
||||
<tr :style="item.ganancia < 0 ? 'background:#fff5f5' : ''">
|
||||
<td class="text-bold" x-text="item.tipo"></td>
|
||||
<td class="text-center">
|
||||
<span class="grade-badge" x-text="item.grado"></span>
|
||||
</td>
|
||||
<td class="text-mono text-right" x-text="item.cristalesUsados + ' / ' + item.cristalesDisponibles"></td>
|
||||
<td class="text-mono text-right" x-text="item.oreNecesario"></td>
|
||||
<td class="text-mono text-right" x-text="item.crafteosPosibles"></td>
|
||||
<td class="text-mono text-right" x-text="item.shotsObtenidos"></td>
|
||||
<td class="text-mono text-right" x-text="fmtAdena(item.costoTotal)"></td>
|
||||
<td class="text-mono text-right" x-text="fmtAdena(item.valorVenta)"></td>
|
||||
<td class="text-mono text-right text-bold"
|
||||
:class="item.ganancia > 0 ? 'text-success' : item.ganancia < 0 ? 'text-error' : ''"
|
||||
x-text="fmtAdena(item.ganancia)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<footer style="padding:0.75rem 1.5rem;border-top:1px solid var(--pico-muted-border-color);display:flex;justify-content:flex-end">
|
||||
<button type="button" @click="closeModal()" class="secondary">Cerrar</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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/app.js"></script>
|
||||
<script src="/static/js/format.js"></script>
|
||||
<script defer src="/static/js/alpine.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('historialSection', () => ({
|
||||
store() { return Alpine.store('app'); },
|
||||
runs: [],
|
||||
stats: null,
|
||||
loading: true,
|
||||
modalRunId: null,
|
||||
modalDetails: null,
|
||||
modalLoading: false,
|
||||
modalError: null,
|
||||
sort: { field: 'createdAt', dir: 'desc' },
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [runs, stats] = await Promise.all([
|
||||
window.api.getHistoryRuns(),
|
||||
window.api.getHistoryStats(),
|
||||
]);
|
||||
this.runs = runs || [];
|
||||
this.stats = stats;
|
||||
} catch (e) {
|
||||
console.error('history load error', e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openRun(id) {
|
||||
this.modalRunId = id;
|
||||
this.modalDetails = null;
|
||||
this.modalError = null;
|
||||
this.modalLoading = true;
|
||||
try {
|
||||
this.modalDetails = await window.api.getHistoryRun(id);
|
||||
} catch (e) {
|
||||
this.modalError = e.message || 'Error al cargar detalle';
|
||||
} finally {
|
||||
this.modalLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.modalRunId = null;
|
||||
this.modalDetails = null;
|
||||
},
|
||||
|
||||
async deleteRun(id) {
|
||||
const run = this.runs.find(r => r.id === id);
|
||||
if (!confirm('Borrar la corrida "' + (run?.label || 'sin label') + '"?')) return;
|
||||
try {
|
||||
await window.api.deleteHistoryRun(id);
|
||||
this.runs = this.runs.filter(r => r.id !== id);
|
||||
if (this.modalRunId === id) this.closeModal();
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
alert('Error al borrar: ' + (e.message || e));
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
get sortedRuns() {
|
||||
const field = this.sort.field;
|
||||
const dir = this.sort.dir === 'asc' ? 1 : -1;
|
||||
return [...this.runs].sort((a, b) => {
|
||||
let va = a[field];
|
||||
let vb = b[field];
|
||||
if (va == null) return 1;
|
||||
if (vb == null) return -1;
|
||||
if (typeof va === 'string') {
|
||||
return dir * va.localeCompare(vb);
|
||||
}
|
||||
if (va < vb) return -1 * dir;
|
||||
if (va > vb) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
sortBy(field) {
|
||||
if (this.sort.field === field) {
|
||||
this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sort.field = field;
|
||||
this.sort.dir = 'desc';
|
||||
}
|
||||
},
|
||||
|
||||
sortIcon(field) {
|
||||
if (this.sort.field !== field) return '↕';
|
||||
return this.sort.dir === 'asc' ? '↑' : '↓';
|
||||
},
|
||||
|
||||
deltaVsAvg(profit) {
|
||||
if (!this.stats || !this.stats.avgProfit) return null;
|
||||
return profit - this.stats.avgProfit;
|
||||
},
|
||||
|
||||
deltaClass(delta) {
|
||||
if (!delta) return '';
|
||||
return delta > 0 ? 'delta-up' : 'delta-down';
|
||||
},
|
||||
|
||||
deltaIcon(delta) {
|
||||
if (!delta) return '';
|
||||
return delta > 0 ? '↑' : '↓';
|
||||
},
|
||||
|
||||
get sparklineBars() {
|
||||
const data = (this.runs || []).slice(0, 10).map(r => ({ profit: r.totalProfit, label: this.fmtDate(r.createdAt) }));
|
||||
if (!data.length) return [];
|
||||
const max = Math.max(...data.map(d => d.profit));
|
||||
const min = Math.min(...data.map(d => d.profit));
|
||||
const range = Math.abs(max - min) || 1;
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
heightPct: d.profit === 0 ? 5 : Math.max(10, Math.abs(d.profit) / range * 90)
|
||||
}));
|
||||
},
|
||||
|
||||
fmtAdena(n) {
|
||||
return window.fmt.adena(n);
|
||||
},
|
||||
|
||||
fmtNumber(n) {
|
||||
return window.fmt.number(n);
|
||||
},
|
||||
|
||||
fmtPct(pct) {
|
||||
return window.fmt.percent(pct);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString('es-CL', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
},
|
||||
|
||||
fmtDateTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('es-CL', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
marginPct(details) {
|
||||
if (!details || !details.totalCost) return 0;
|
||||
return Math.round((details.totalProfit / details.totalCost) * 1000) / 10;
|
||||
},
|
||||
|
||||
costPerShot(details) {
|
||||
if (!details || !details.totalShots) return 0;
|
||||
return Math.round(details.totalCost / details.totalShots);
|
||||
},
|
||||
|
||||
profitPerShot(details) {
|
||||
if (!details || !details.totalShots) return 0;
|
||||
return Math.round(details.totalProfit / details.totalShots);
|
||||
},
|
||||
|
||||
totalCrafteos() {
|
||||
if (!this.modalDetails?.items) return 0;
|
||||
return this.modalDetails.items.reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
|
||||
},
|
||||
|
||||
snapshotCristales() {
|
||||
if (!this.modalDetails?.snapshot?.insumos?.cristales) return {};
|
||||
return this.modalDetails.snapshot.insumos.cristales;
|
||||
},
|
||||
|
||||
snapshotOre(key) {
|
||||
if (!this.modalDetails?.snapshot?.insumos) return 0;
|
||||
return this.modalDetails.snapshot.insumos[key] || 0;
|
||||
},
|
||||
|
||||
snapshotVenta() {
|
||||
if (!this.modalDetails?.snapshot?.insumos?.venta) return {};
|
||||
const v = this.modalDetails.snapshot.insumos.venta;
|
||||
const result = {};
|
||||
for (const [tipo, grados] of Object.entries(v)) {
|
||||
for (const [grado, precio] of Object.entries(grados)) {
|
||||
result[tipo + '-' + grado] = precio;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
currentCristal(grado) {
|
||||
const state = this.store()?.state;
|
||||
if (!state?.insumos?.cristales?.[grado]) return null;
|
||||
return state.insumos.cristales[grado];
|
||||
},
|
||||
|
||||
currentOre(key) {
|
||||
const state = this.store()?.state;
|
||||
if (!state?.insumos?.[key]) return null;
|
||||
return state.insumos[key];
|
||||
},
|
||||
|
||||
currentVenta(key) {
|
||||
const state = this.store()?.state;
|
||||
if (!state?.insumos?.venta) return null;
|
||||
const [tipo, grado] = key.split('-');
|
||||
if (!state.insumos.venta[tipo]?.[grado]) return null;
|
||||
return state.insumos.venta[tipo][grado];
|
||||
},
|
||||
|
||||
pctDiff(current, saved) {
|
||||
if (!saved) return '?';
|
||||
return Math.round((current - saved) / saved * 100) + '%';
|
||||
},
|
||||
|
||||
exportCSV() {
|
||||
const headers = ['Fecha', 'Label', 'Costo', 'Venta', 'Ganancia', 'Margen%', 'Costo/shot', 'Shots', 'Cristales', 'Ore', 'Items'];
|
||||
const rows = this.runs.map(r => [
|
||||
this.fmtDateTime(r.createdAt),
|
||||
r.label || '',
|
||||
r.totalCost,
|
||||
r.totalSale,
|
||||
r.totalProfit,
|
||||
r.profitMarginPct,
|
||||
r.costPerShot,
|
||||
r.totalShots,
|
||||
r.totalCristalesUsed,
|
||||
r.totalOreUsed,
|
||||
r.itemCount,
|
||||
]);
|
||||
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');
|
||||
this.downloadFile('corridas_' + this.store()?.user?.username + '_' + new Date().toISOString().slice(0, 10) + '.csv', csv, 'text/csv');
|
||||
},
|
||||
|
||||
exportJSON() {
|
||||
const data = { exportedAt: new Date().toISOString(), runs: this.runs, stats: this.stats };
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
this.downloadFile('corridas_' + this.store()?.user?.username + '_' + new Date().toISOString().slice(0, 10) + '.json', json, 'application/json');
|
||||
},
|
||||
|
||||
downloadFile(filename, content, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user