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 java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -18,7 +21,10 @@ public class HistoryService {
|
||||
public List<RunSummary> listForUser(UUID userId) {
|
||||
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
|
||||
.stream()
|
||||
.map(RunSummary::from)
|
||||
.map(r -> {
|
||||
List<RunItem> items = parseItems(r.itemsJson);
|
||||
return RunSummary.from(r, items);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ public class HistoryService {
|
||||
throw new RuntimeException("Failed to serialize run payload", e);
|
||||
}
|
||||
entity.persist();
|
||||
return RunSummary.from(entity);
|
||||
return RunSummary.from(entity, input.items());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -68,21 +74,37 @@ public class HistoryService {
|
||||
"userId = ?1 ORDER BY createdAt DESC", userId);
|
||||
|
||||
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 totalSale = 0;
|
||||
long totalProfit = 0;
|
||||
long totalShots = 0;
|
||||
long totalCristalesUsed = 0;
|
||||
long totalOreUsed = 0;
|
||||
int totalItems = 0;
|
||||
ProductionRun best = runs.get(0);
|
||||
ProductionRun worst = runs.get(0);
|
||||
Map<String, Long> tiposBreakdown = new LinkedHashMap<>();
|
||||
|
||||
for (ProductionRun r : runs) {
|
||||
totalCost += r.totalCost;
|
||||
totalSale += r.totalSale;
|
||||
totalProfit += r.totalProfit;
|
||||
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 < worst.totalProfit) worst = r;
|
||||
}
|
||||
@@ -97,18 +119,44 @@ public class HistoryService {
|
||||
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(
|
||||
n,
|
||||
totalCost,
|
||||
totalSale,
|
||||
totalProfit,
|
||||
totalShots,
|
||||
totalCristalesUsed,
|
||||
totalOreUsed,
|
||||
totalProfit / n,
|
||||
totalCost / n,
|
||||
totalSale / n,
|
||||
RunSummary.from(best),
|
||||
RunSummary.from(worst),
|
||||
Math.round(avgMargin * 10.0) / 10.0,
|
||||
avgCostPerShot,
|
||||
avgProfitPerShot,
|
||||
RunSummary.from(best, bestItems),
|
||||
RunSummary.from(worst, worstItems),
|
||||
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 java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RegisterForReflection
|
||||
public record HistoryStats(
|
||||
int totalRuns,
|
||||
@@ -9,11 +12,18 @@ public record HistoryStats(
|
||||
long totalSale,
|
||||
long totalProfit,
|
||||
long totalShots,
|
||||
long totalCristalesUsed,
|
||||
long totalOreUsed,
|
||||
long avgProfit,
|
||||
long avgCost,
|
||||
long avgSale,
|
||||
double avgProfitMarginPct,
|
||||
long avgCostPerShot,
|
||||
long avgProfitPerShot,
|
||||
RunSummary bestRun,
|
||||
RunSummary worstRun,
|
||||
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 java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RegisterForReflection
|
||||
@@ -15,9 +16,25 @@ public record RunSummary(
|
||||
long totalProfit,
|
||||
long totalShots,
|
||||
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(
|
||||
r.id,
|
||||
r.createdAt,
|
||||
@@ -27,6 +44,12 @@ public record RunSummary(
|
||||
r.totalProfit,
|
||||
r.totalShots,
|
||||
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