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:
@@ -6,7 +6,10 @@ import jakarta.enterprise.context.ApplicationScoped;
|
|||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -18,7 +21,10 @@ public class HistoryService {
|
|||||||
public List<RunSummary> listForUser(UUID userId) {
|
public List<RunSummary> listForUser(UUID userId) {
|
||||||
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
|
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(RunSummary::from)
|
.map(r -> {
|
||||||
|
List<RunItem> items = parseItems(r.itemsJson);
|
||||||
|
return RunSummary.from(r, items);
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ public class HistoryService {
|
|||||||
throw new RuntimeException("Failed to serialize run payload", e);
|
throw new RuntimeException("Failed to serialize run payload", e);
|
||||||
}
|
}
|
||||||
entity.persist();
|
entity.persist();
|
||||||
return RunSummary.from(entity);
|
return RunSummary.from(entity, input.items());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -68,21 +74,37 @@ public class HistoryService {
|
|||||||
"userId = ?1 ORDER BY createdAt DESC", userId);
|
"userId = ?1 ORDER BY createdAt DESC", userId);
|
||||||
|
|
||||||
if (runs.isEmpty()) {
|
if (runs.isEmpty()) {
|
||||||
return new HistoryStats(0, 0, 0, 0, 0, 0, 0, 0, null, null, 0, 0);
|
return new HistoryStats(
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0, 0,
|
||||||
|
null, null, 0, 0, 0, Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
long totalCost = 0;
|
long totalCost = 0;
|
||||||
long totalSale = 0;
|
long totalSale = 0;
|
||||||
long totalProfit = 0;
|
long totalProfit = 0;
|
||||||
long totalShots = 0;
|
long totalShots = 0;
|
||||||
|
long totalCristalesUsed = 0;
|
||||||
|
long totalOreUsed = 0;
|
||||||
|
int totalItems = 0;
|
||||||
ProductionRun best = runs.get(0);
|
ProductionRun best = runs.get(0);
|
||||||
ProductionRun worst = runs.get(0);
|
ProductionRun worst = runs.get(0);
|
||||||
|
Map<String, Long> tiposBreakdown = new LinkedHashMap<>();
|
||||||
|
|
||||||
for (ProductionRun r : runs) {
|
for (ProductionRun r : runs) {
|
||||||
totalCost += r.totalCost;
|
totalCost += r.totalCost;
|
||||||
totalSale += r.totalSale;
|
totalSale += r.totalSale;
|
||||||
totalProfit += r.totalProfit;
|
totalProfit += r.totalProfit;
|
||||||
totalShots += r.totalShots;
|
totalShots += r.totalShots;
|
||||||
|
totalCristalesUsed += r.totalCristalesUsed;
|
||||||
|
totalOreUsed += r.totalOreUsed;
|
||||||
|
|
||||||
|
List<RunItem> items = parseItems(r.itemsJson);
|
||||||
|
totalItems += items.size();
|
||||||
|
|
||||||
|
for (RunItem item : items) {
|
||||||
|
tiposBreakdown.merge(item.tipo(), item.ganancia(), Long::sum);
|
||||||
|
}
|
||||||
|
|
||||||
if (r.totalProfit > best.totalProfit) best = r;
|
if (r.totalProfit > best.totalProfit) best = r;
|
||||||
if (r.totalProfit < worst.totalProfit) worst = r;
|
if (r.totalProfit < worst.totalProfit) worst = r;
|
||||||
}
|
}
|
||||||
@@ -97,18 +119,44 @@ public class HistoryService {
|
|||||||
if (i < n5) sum5 += runs.get(i).totalProfit;
|
if (i < n5) sum5 += runs.get(i).totalProfit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double avgMargin = totalCost > 0
|
||||||
|
? (double) totalProfit * 100.0 / totalCost
|
||||||
|
: 0.0;
|
||||||
|
long avgCostPerShot = totalShots > 0 ? totalCost / totalShots : 0;
|
||||||
|
long avgProfitPerShot = totalShots > 0 ? totalProfit / totalShots : 0;
|
||||||
|
|
||||||
|
List<RunItem> bestItems = parseItems(best.itemsJson);
|
||||||
|
List<RunItem> worstItems = parseItems(worst.itemsJson);
|
||||||
|
|
||||||
return new HistoryStats(
|
return new HistoryStats(
|
||||||
n,
|
n,
|
||||||
totalCost,
|
totalCost,
|
||||||
totalSale,
|
totalSale,
|
||||||
totalProfit,
|
totalProfit,
|
||||||
totalShots,
|
totalShots,
|
||||||
|
totalCristalesUsed,
|
||||||
|
totalOreUsed,
|
||||||
totalProfit / n,
|
totalProfit / n,
|
||||||
totalCost / n,
|
totalCost / n,
|
||||||
totalSale / n,
|
totalSale / n,
|
||||||
RunSummary.from(best),
|
Math.round(avgMargin * 10.0) / 10.0,
|
||||||
RunSummary.from(worst),
|
avgCostPerShot,
|
||||||
|
avgProfitPerShot,
|
||||||
|
RunSummary.from(best, bestItems),
|
||||||
|
RunSummary.from(worst, worstItems),
|
||||||
n5 > 0 ? sum5 / n5 : 0,
|
n5 > 0 ? sum5 / n5 : 0,
|
||||||
n10 > 0 ? sum10 / n10 : 0);
|
n10 > 0 ? sum10 / n10 : 0,
|
||||||
|
totalItems,
|
||||||
|
tiposBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<RunItem> parseItems(String json) {
|
||||||
|
if (json == null || json.isBlank()) return List.of();
|
||||||
|
try {
|
||||||
|
return mapper.readValue(json, mapper.getTypeFactory()
|
||||||
|
.constructCollectionType(List.class, RunItem.class));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.l2.shots.history;
|
|||||||
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
public record HistoryStats(
|
public record HistoryStats(
|
||||||
int totalRuns,
|
int totalRuns,
|
||||||
@@ -9,11 +12,18 @@ public record HistoryStats(
|
|||||||
long totalSale,
|
long totalSale,
|
||||||
long totalProfit,
|
long totalProfit,
|
||||||
long totalShots,
|
long totalShots,
|
||||||
|
long totalCristalesUsed,
|
||||||
|
long totalOreUsed,
|
||||||
long avgProfit,
|
long avgProfit,
|
||||||
long avgCost,
|
long avgCost,
|
||||||
long avgSale,
|
long avgSale,
|
||||||
|
double avgProfitMarginPct,
|
||||||
|
long avgCostPerShot,
|
||||||
|
long avgProfitPerShot,
|
||||||
RunSummary bestRun,
|
RunSummary bestRun,
|
||||||
RunSummary worstRun,
|
RunSummary worstRun,
|
||||||
long last5Avg,
|
long last5Avg,
|
||||||
long last10Avg) {
|
long last10Avg,
|
||||||
|
int totalItems,
|
||||||
|
Map<String, Long> tiposBreakdown) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.l2.shots.history;
|
|||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@@ -15,9 +16,25 @@ public record RunSummary(
|
|||||||
long totalProfit,
|
long totalProfit,
|
||||||
long totalShots,
|
long totalShots,
|
||||||
long totalCristalesUsed,
|
long totalCristalesUsed,
|
||||||
long totalOreUsed) {
|
long totalOreUsed,
|
||||||
|
double profitMarginPct,
|
||||||
|
long costPerShot,
|
||||||
|
long profitPerShot,
|
||||||
|
int itemCount,
|
||||||
|
List<String> tiposUsados,
|
||||||
|
boolean wasProfitable) {
|
||||||
|
|
||||||
public static RunSummary from(ProductionRun r) {
|
public static RunSummary from(ProductionRun r, List<RunItem> items) {
|
||||||
|
List<String> tipos = items.stream()
|
||||||
|
.map(i -> i.tipo())
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
int count = items.size();
|
||||||
|
double marginPct = r.totalCost > 0
|
||||||
|
? (double) r.totalProfit * 100.0 / r.totalCost
|
||||||
|
: 0.0;
|
||||||
|
long cps = r.totalShots > 0 ? r.totalCost / r.totalShots : 0;
|
||||||
|
long pps = r.totalShots > 0 ? r.totalProfit / r.totalShots : 0;
|
||||||
return new RunSummary(
|
return new RunSummary(
|
||||||
r.id,
|
r.id,
|
||||||
r.createdAt,
|
r.createdAt,
|
||||||
@@ -27,6 +44,12 @@ public record RunSummary(
|
|||||||
r.totalProfit,
|
r.totalProfit,
|
||||||
r.totalShots,
|
r.totalShots,
|
||||||
r.totalCristalesUsed,
|
r.totalCristalesUsed,
|
||||||
r.totalOreUsed);
|
r.totalOreUsed,
|
||||||
|
Math.round(marginPct * 10.0) / 10.0,
|
||||||
|
cps,
|
||||||
|
pps,
|
||||||
|
count,
|
||||||
|
tipos,
|
||||||
|
r.totalProfit > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
/* ============================================
|
||||||
|
PICO BASE — Flat size, no viewport scaling
|
||||||
|
============================================ */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--pico-font-size: 100%;
|
||||||
|
|
||||||
|
--pico-primary: #059669;
|
||||||
|
--pico-primary-background: #059669;
|
||||||
|
--pico-primary-border: #059669;
|
||||||
|
--pico-primary-color: #ffffff;
|
||||||
|
--pico-primary-underline: rgba(5, 150, 105, 0.5);
|
||||||
|
--pico-primary-hover: #047857;
|
||||||
|
--pico-primary-hover-background: #047857;
|
||||||
|
--pico-primary-hover-border: #047857;
|
||||||
|
--pico-primary-hover-color: #ffffff;
|
||||||
|
--pico-primary-focus: rgba(5, 150, 105, 0.25);
|
||||||
|
--pico-primary-inverse: #ffffff;
|
||||||
|
|
||||||
|
--pico-background-color: #ffffff;
|
||||||
|
--pico-color: #1f2937;
|
||||||
|
--pico-card-background-color: #ffffff;
|
||||||
|
--pico-card-sectioning-background-color: #f9fafb;
|
||||||
|
--pico-card-border-color: #e5e7eb;
|
||||||
|
|
||||||
|
--pico-h1-color: #111827;
|
||||||
|
--pico-h2-color: #1f2937;
|
||||||
|
--pico-h3-color: #1f2937;
|
||||||
|
--pico-h4-color: #1f2937;
|
||||||
|
--pico-h5-color: #1f2937;
|
||||||
|
--pico-h6-color: #1f2937;
|
||||||
|
|
||||||
|
--pico-muted-color: #6b7280;
|
||||||
|
--pico-muted-border-color: #e5e7eb;
|
||||||
|
|
||||||
|
--pico-form-element-background-color: #ffffff;
|
||||||
|
--pico-form-element-border-color: #d1d5db;
|
||||||
|
--pico-form-element-color: #1f2937;
|
||||||
|
--pico-form-element-placeholder-color: #9ca3af;
|
||||||
|
--pico-form-element-focus-color: #059669;
|
||||||
|
--pico-form-element-active-border-color: #059669;
|
||||||
|
--pico-form-element-disabled-background-color: #f9fafb;
|
||||||
|
--pico-form-element-disabled-border-color: #d1d5db;
|
||||||
|
--pico-form-element-disabled-opacity: 0.5;
|
||||||
|
|
||||||
|
--pico-table-border-color: #e5e7eb;
|
||||||
|
--pico-table-row-stripped-background-color: #f9fafb;
|
||||||
|
|
||||||
|
--pico-code-background-color: #f3f4f6;
|
||||||
|
--pico-code-color: #1f2937;
|
||||||
|
|
||||||
|
--pico-spacing: 0.75rem;
|
||||||
|
--pico-block-spacing-vertical: 1rem;
|
||||||
|
--pico-block-spacing-horizontal: 1rem;
|
||||||
|
--pico-typography-spacing-vertical: 0.75rem;
|
||||||
|
--pico-typography-spacing-top: 0.5rem;
|
||||||
|
|
||||||
|
--pico-card-box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--pico-primary-background: #059669;
|
||||||
|
--pico-primary-border: #059669;
|
||||||
|
--pico-primary-color: #ffffff;
|
||||||
|
--pico-background-color: #ffffff;
|
||||||
|
--pico-color: #1f2937;
|
||||||
|
--pico-card-background-color: #ffffff;
|
||||||
|
--pico-card-sectioning-background-color: #f9fafb;
|
||||||
|
--pico-card-border-color: #e5e7eb;
|
||||||
|
--pico-h1-color: #111827;
|
||||||
|
--pico-h2-color: #1f2937;
|
||||||
|
--pico-muted-color: #6b7280;
|
||||||
|
--pico-muted-border-color: #e5e7eb;
|
||||||
|
--pico-form-element-background-color: #ffffff;
|
||||||
|
--pico-form-element-border-color: #d1d5db;
|
||||||
|
--pico-form-element-color: #1f2937;
|
||||||
|
--pico-table-border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TYPOGRAPHY — Compact heading scale
|
||||||
|
============================================ */
|
||||||
|
h1 { font-size: 1.5rem; margin: 0 0 0.75rem; line-height: 1.25; font-weight: 700; }
|
||||||
|
h2 { font-size: 1.125rem; margin: 0 0 0.5rem; line-height: 1.3; font-weight: 600; }
|
||||||
|
h3 { font-size: 1rem; margin: 0 0 0.375rem; line-height: 1.35; font-weight: 600; }
|
||||||
|
h4 { font-size: 0.875rem; margin: 0 0 0.25rem; line-height: 1.4; font-weight: 600; }
|
||||||
|
h5, h6 { font-size: 0.875rem; margin: 0 0 0.25rem; line-height: 1.4; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM ELEMENTS — Compact
|
||||||
|
============================================ */
|
||||||
|
input, select, textarea {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
margin: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
label > span {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BUTTONS — Compact
|
||||||
|
============================================ */
|
||||||
|
button {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ARTICLES — Flat, no card shadow
|
||||||
|
============================================ */
|
||||||
|
article {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
article > header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
article > header > h2 {
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article > header > p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TABLES — Compact
|
||||||
|
============================================ */
|
||||||
|
table {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th, table td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||||
|
background: var(--pico-card-sectioning-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
table tbody tr:hover { background: var(--pico-tr-hover-background-color); }
|
||||||
|
table tfoot tr { background: var(--pico-muted-border-color); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CONTAINER — Wider for data tables
|
||||||
|
============================================ */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ANIMATIONS
|
||||||
|
============================================ */
|
||||||
|
@keyframes sparkle {
|
||||||
|
0%, 100% { opacity: 0.6; transform: scale(0.9); }
|
||||||
|
50% { opacity: 1; transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkle {
|
||||||
|
animation: sparkle 2.5s ease-in-out infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UTILITIES
|
||||||
|
============================================ */
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-mono { font-family: monospace; }
|
||||||
|
.text-sm { font-size: 0.875rem; }
|
||||||
|
.text-xs { font-size: 0.75rem; }
|
||||||
|
.text-muted { color: var(--pico-muted-color); }
|
||||||
|
.text-success { color: #059669; }
|
||||||
|
.text-error { color: #dc2626; }
|
||||||
|
.text-bold { font-weight: 600; }
|
||||||
|
|
||||||
|
.flex-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.flex-col { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-end { justify-content: flex-end; }
|
||||||
|
|
||||||
|
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; }
|
||||||
|
.grid-5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; }
|
||||||
|
.grid-8 { display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.75rem; }
|
||||||
|
.grid-auto { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||||
|
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-3 { margin-bottom: 0.75rem; }
|
||||||
|
.mb-4 { margin-bottom: 1rem; }
|
||||||
|
.mb-6 { margin-bottom: 1.5rem; }
|
||||||
|
.mt-1 { margin-top: 0.25rem; }
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-4 { margin-top: 1rem; }
|
||||||
|
.p-4 { padding: 1rem; }
|
||||||
|
.p-6 { padding: 1.5rem; }
|
||||||
|
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||||
|
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||||
|
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.max-w-md { max-width: 28rem; margin-inline: auto; }
|
||||||
|
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HEADER — Compact global header
|
||||||
|
============================================ */
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.125rem !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 0.25rem !important;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
header hgroup {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.header-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.header-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button.link-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--pico-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions button.link-action:hover {
|
||||||
|
color: var(--pico-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--pico-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.link-action:hover {
|
||||||
|
color: var(--pico-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODAL
|
||||||
|
============================================ */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 50; padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: var(--pico-card-background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 90vw; max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BADGES
|
||||||
|
============================================ */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-admin { background: #dbeafe; color: #1e3a8a; }
|
||||||
|
.badge-warning { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-success { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TABS
|
||||||
|
============================================ */
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.tabs li { display: contents; }
|
||||||
|
.tabs a {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.tabs a.active {
|
||||||
|
border-bottom-color: var(--pico-primary);
|
||||||
|
color: var(--pico-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TABLE CELLS — For grid-based inputs
|
||||||
|
============================================ */
|
||||||
|
.grade-badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 1.75rem; height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pico-muted-border-color);
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-label {
|
||||||
|
font-size: 0.75rem; font-weight: 500;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.cell-input { padding: 0.375rem 0.5rem; }
|
||||||
|
.cell-calculated { font-family: monospace; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ALERTS & MESSAGES
|
||||||
|
============================================ */
|
||||||
|
.bg-warning { background: #fef3c7; border: 1px solid #fcd34d; color: #92400e; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.bg-success-light { background: #d1fae5; border: 1px solid #6ee7b7; color: #065f46; padding: 0.75rem 1rem; border-radius: 4px; }
|
||||||
|
.bg-error-light { background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 0.75rem 1rem; border-radius: 4px; }
|
||||||
|
|
||||||
|
.error-msg { color: #991b1b; background: #fee2e2; border: 1px solid #fca5a5; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; }
|
||||||
|
.success-msg { color: #065f46; background: #d1fae5; border: 1px solid #6ee7b7; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATS GRID
|
||||||
|
============================================ */
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||||
|
@media (max-width: 768px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
.w-max { width: max-content; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOGIN PAGE — centered card with branding
|
||||||
|
============================================ */
|
||||||
|
.login-page {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 50%, #f0f9ff 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
background: white;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
display: block;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(5, 150, 105, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — SPARKLINE
|
||||||
|
============================================ */
|
||||||
|
.sparkline-container {
|
||||||
|
background: var(--pico-card-sectioning-background-color);
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
height: 64px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline-bar {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-positive { background: #059669; }
|
||||||
|
.bar-negative { background: #dc2626; }
|
||||||
|
|
||||||
|
.sparkline-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — TIPO BREAKDOWN
|
||||||
|
============================================ */
|
||||||
|
.tipos-breakdown {
|
||||||
|
background: var(--pico-card-sectioning-background-color);
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipos-bars { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.tipo-bar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr 80px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipo-bar-track {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--pico-muted-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipo-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-positive { background: #059669; }
|
||||||
|
.fill-negative { background: #dc2626; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — SORTABLE COLUMNS
|
||||||
|
============================================ */
|
||||||
|
.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable:hover { color: var(--pico-primary); }
|
||||||
|
|
||||||
|
.sort-icon {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — DELTA / VARIANCE
|
||||||
|
============================================ */
|
||||||
|
.delta-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-up { background: #d1fae5; color: #065f46; }
|
||||||
|
.delta-down { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — TIPO CHIPS
|
||||||
|
============================================ */
|
||||||
|
.tipo-chip {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--pico-muted-border-color);
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — METRIC CARDS
|
||||||
|
============================================ */
|
||||||
|
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
|
||||||
|
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--pico-card-sectioning-background-color);
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card-sm {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-md { font-size: 1rem; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — BADGE RUN
|
||||||
|
============================================ */
|
||||||
|
.badge-run {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-profit { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-loss { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — SNAPSHOT SECTION
|
||||||
|
============================================ */
|
||||||
|
.snapshot-section {
|
||||||
|
background: var(--pico-card-sectioning-background-color);
|
||||||
|
border: 1px solid var(--pico-muted-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 60px auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HISTORIAL — DIFF BADGES
|
||||||
|
============================================ */
|
||||||
|
.diff-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-up { background: #fee2e2; color: #991b1b; }
|
||||||
|
.diff-down { background: #d1fae5; color: #065f46; }
|
||||||
|
.diff-same { background: var(--pico-muted-border-color); color: var(--pico-muted-color); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MODAL — LARGER
|
||||||
|
============================================ */
|
||||||
|
.modal-lg .modal-content {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GAPS
|
||||||
|
============================================ */
|
||||||
|
.gap-1 { gap: 0.25rem; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
window.fmt = {
|
||||||
|
adena(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('es-CL');
|
||||||
|
},
|
||||||
|
number(n) {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return n.toLocaleString('es-CL');
|
||||||
|
},
|
||||||
|
percent(pct) {
|
||||||
|
if (pct == null) return '—';
|
||||||
|
const sign = pct > 0 ? '+' : '';
|
||||||
|
return sign + pct.toFixed(1).replace('.', ',') + '%';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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