feat: add character sales tracking with 12h countdown timer
CI / Build Native (push) Failing after 1m11s

- New page /ventas to track characters selling items
- Default 12h duration per character sale
- Real-time countdown with color-coded badges (green/yellow/red/gray)
- Sound alert toggle when sales expire
- CRUD API: GET/POST/DELETE /api/sales
- Flyway migration V2 for character_sales table
- Private per-user (same user sees only their own characters)
- Expired sales remain visible with 'Expirado' badge until manually deleted
This commit is contained in:
2026-08-18 09:22:41 -04:00
parent 8ca11252d7
commit af323da3d3
11 changed files with 848 additions and 0 deletions
@@ -0,0 +1,36 @@
package com.l2.shots.sales;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "character_sales", indexes = {
@Index(name = "idx_char_sales_user", columnList = "user_id, started_at")
})
public class CharacterSale extends PanacheEntityBase {
@Id
public UUID id;
@Column(name = "user_id", nullable = false)
public UUID userId;
@Column(name = "character_name", nullable = false, length = 50)
public String characterName;
@Column(name = "items_description", nullable = false, columnDefinition = "TEXT")
public String itemsDescription;
@Column(name = "started_at", nullable = false)
public Instant startedAt;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
}
@@ -0,0 +1,16 @@
package com.l2.shots.sales;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.UUID;
@RegisterForReflection
public record CharacterSaleDto(
UUID id,
String characterName,
String itemsDescription,
Instant startedAt,
Instant expiresAt,
Instant now) {
}
@@ -0,0 +1,27 @@
package com.l2.shots.sales;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
@RegisterForReflection
public record CharacterSaleIn(
String characterName,
String itemsDescription,
Instant startedAt) {
public CharacterSaleIn {
if (characterName == null || characterName.isBlank()) {
throw new IllegalArgumentException("characterName is required");
}
if (characterName.length() > 50) {
throw new IllegalArgumentException("characterName must be 50 chars or less");
}
if (itemsDescription == null || itemsDescription.isBlank()) {
throw new IllegalArgumentException("itemsDescription is required");
}
if (itemsDescription.length() > 2000) {
throw new IllegalArgumentException("itemsDescription must be 2000 chars or less");
}
}
}
@@ -0,0 +1,88 @@
package com.l2.shots.sales;
import com.l2.shots.auth.JwtCookieAuth;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Path("/api/sales")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CharacterSaleResource {
@Inject
CharacterSaleService saleService;
@Inject
JwtCookieAuth jwtCookieAuth;
@GET
public Response list(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
List<CharacterSaleDto> sales = saleService.listForUser(userId.get());
return Response.ok(sales).build();
}
@POST
public Response create(@Context HttpHeaders headers, CharacterSaleIn input) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
if (input == null) {
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
}
try {
CharacterSaleIn validated = new CharacterSaleIn(
input.characterName(),
input.itemsDescription(),
input.startedAt());
CharacterSaleDto saved = saleService.create(userId.get(), validated);
return Response.status(201).entity(saved).build();
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"" + e.getMessage() + "\"}").build();
}
}
@DELETE
@Path("/{id}")
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
UUID id;
try {
id = UUID.fromString(idStr);
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
}
boolean deleted = saleService.deleteForUser(userId.get(), id);
return deleted ? Response.noContent().build() : Response.status(404).build();
}
private Optional<UUID> extractUserId(HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) return Optional.empty();
try {
return Optional.of(UUID.fromString(jwt.get().getSubject()));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
}
@@ -0,0 +1,59 @@
package com.l2.shots.sales;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class CharacterSaleService {
public static final Duration DEFAULT_DURATION = Duration.ofHours(12);
public List<CharacterSaleDto> listForUser(UUID userId) {
return CharacterSale.<CharacterSale>list("userId = ?1 ORDER BY createdAt DESC", userId)
.stream()
.map(this::toDto)
.toList();
}
public Optional<CharacterSaleDto> getById(UUID userId, UUID id) {
CharacterSale entity = CharacterSale.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return Optional.empty();
return Optional.of(toDto(entity));
}
@Transactional
public CharacterSaleDto create(UUID userId, CharacterSaleIn input) {
CharacterSale entity = new CharacterSale();
entity.id = UUID.randomUUID();
entity.userId = userId;
entity.characterName = input.characterName();
entity.itemsDescription = input.itemsDescription();
entity.startedAt = input.startedAt() != null ? input.startedAt() : Instant.now();
entity.createdAt = Instant.now();
entity.persist();
return toDto(entity);
}
@Transactional
public boolean deleteForUser(UUID userId, UUID id) {
return CharacterSale.delete("id = ?1 AND userId = ?2", id, userId) > 0;
}
private CharacterSaleDto toDto(CharacterSale entity) {
Instant now = Instant.now();
Instant expiresAt = entity.startedAt.plus(DEFAULT_DURATION);
return new CharacterSaleDto(
entity.id,
entity.characterName,
entity.itemsDescription,
entity.startedAt,
expiresAt,
now);
}
}
@@ -0,0 +1,173 @@
package com.l2.shots.ui;
import com.l2.shots.auth.AuthMeResponse;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.util.Optional;
@Path("/")
@Produces(MediaType.TEXT_HTML)
public class PageResource {
@Inject
TemplateData templateData;
@CheckedTemplate
static class Pages {
public static native TemplateInstance login();
public static native TemplateInstance register();
public static native TemplateInstance changePassword();
public static native TemplateInstance insumos();
public static native TemplateInstance formulas();
public static native TemplateInstance calculadora();
public static native TemplateInstance historial();
public static native TemplateInstance usuarios();
public static native TemplateInstance ventas();
}
private Optional<AuthMeResponse> currentUser(HttpHeaders headers) {
return templateData.currentUser(headers);
}
@GET
public Response root(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.seeOther(URI.create("/insumos")).build();
}
@GET
@Path("/login")
public Response login(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isPresent()) {
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.login().render()).build();
}
@GET
@Path("/register")
public Response register(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isPresent()) {
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.register().render()).build();
}
@GET
@Path("/change-password")
public Response changePassword(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (!Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.changePassword().render()).build();
}
@GET
@Path("/insumos")
public Response insumos(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.ok(Pages.insumos().render()).build();
}
@GET
@Path("/formulas")
public Response formulas(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.ok(Pages.formulas().render()).build();
}
@GET
@Path("/calculadora")
public Response calculadora(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.ok(Pages.calculadora().render()).build();
}
@GET
@Path("/historial")
public Response historial(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.ok(Pages.historial().render()).build();
}
@GET
@Path("/usuarios")
public Response usuarios(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
if (!Boolean.TRUE.equals(user.get().isAdmin())) {
return Response.seeOther(URI.create("/insumos")).build();
}
return Response.ok(Pages.usuarios().render()).build();
}
@GET
@Path("/ventas")
public Response ventas(@Context HttpHeaders headers) {
Optional<AuthMeResponse> user = currentUser(headers);
if (user.isEmpty()) {
return Response.seeOther(URI.create("/login")).build();
}
if (Boolean.TRUE.equals(user.get().mustChangePassword())) {
return Response.seeOther(URI.create("/change-password")).build();
}
return Response.ok(Pages.ventas().render()).build();
}
}
@@ -212,6 +212,7 @@ table tfoot tr { background: var(--pico-muted-border-color); }
.text-muted { color: var(--pico-muted-color); }
.text-success { color: #059669; }
.text-error { color: #dc2626; }
.text-warning { color: #d97706; }
.text-bold { font-weight: 600; }
.flex-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
@@ -357,6 +358,7 @@ header {
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-muted { background: #f3f4f6; color: #6b7280; }
/* ============================================
TABS
@@ -605,6 +607,7 @@ header {
}
.text-md { font-size: 1rem; }
.text-lg { font-size: 1.125rem; }
/* ============================================
HISTORIAL — BADGE RUN
@@ -619,6 +622,8 @@ header {
.badge-profit { background: #d1fae5; color: #065f46; }
.badge-loss { background: #fee2e2; color: #991b1b; }
.badge-craft { background: #d1fae5; color: #065f46; }
.badge-buy { background: #fee2e2; color: #991b1b; }
/* ============================================
HISTORIAL — SNAPSHOT SECTION
@@ -674,3 +679,15 @@ header {
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
/* ============================================
RANKING ROWS
============================================ */
.row-profit { background: #f0fdf4; }
.row-loss { background: #fff5f5; }
/* ============================================
SALES CARDS
============================================ */
.card-expired { opacity: 0.7; }
.card-urgent { border-left: 3px solid #dc2626; }
@@ -0,0 +1,127 @@
window.api = {
async request(path, options = {}) {
const res = await fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (res.status === 204 || res.status === 201) return null;
const text = await res.text();
let body = null;
if (text) {
try { body = JSON.parse(text); } catch { body = text; }
}
if (!res.ok) {
const message = body && typeof body === 'object' && 'error' in body
? String(body.error)
: `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.body = body;
throw err;
}
return body;
},
me() {
return this.request('/api/auth/me').catch(e => {
if (e.status === 401) return null;
throw e;
});
},
login(username, password) {
return this.request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
},
register(username, password) {
return this.request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
},
logout() {
return this.request('/api/auth/logout', { method: 'POST' });
},
changePassword(currentPassword, newPassword) {
return this.request('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
},
adminResetPassword(username, newPassword) {
return this.request('/api/auth/admin/reset-password', {
method: 'POST',
body: JSON.stringify({ username, newPassword }),
});
},
adminListUsers() {
return this.request('/api/auth/admin/users');
},
getState() {
return this.request('/api/state').catch(e => {
if (e.status === 404) return null;
throw e;
});
},
putState(state) {
return this.request('/api/state', {
method: 'PUT',
body: JSON.stringify(state),
});
},
resetState() {
return this.request('/api/state', { method: 'DELETE' });
},
getHistoryRuns() {
return this.request('/api/history/runs');
},
getHistoryRun(id) {
return this.request(`/api/history/runs/${id}`);
},
saveHistoryRun(input) {
return this.request('/api/history/runs', {
method: 'POST',
body: JSON.stringify(input),
});
},
deleteHistoryRun(id) {
return this.request(`/api/history/runs/${id}`, { method: 'DELETE' });
},
getHistoryStats() {
return this.request('/api/history/stats');
},
listSales() {
return this.request('/api/sales');
},
createSale(payload) {
return this.request('/api/sales', {
method: 'POST',
body: JSON.stringify(payload),
});
},
deleteSale(id) {
return this.request(`/api/sales/${id}`, { method: 'DELETE' });
},
};
@@ -0,0 +1,10 @@
CREATE TABLE character_sales (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
character_name VARCHAR(50) NOT NULL,
items_description TEXT NOT NULL,
started_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_char_sales_user ON character_sales (user_id, started_at);
@@ -0,0 +1,285 @@
<!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>Ventas — 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="ventasSection()">
<article>
<header>
<h2>Agregar personaje en venta</h2>
<p class="text-sm text-muted">Duración por defecto: 12 horas</p>
</header>
<form @submit.prevent="submitSale()">
<div class="grid" style="grid-template-columns: 1fr 2fr;">
<label>
<span class="text-sm text-muted">Nombre del personaje</span>
<input type="text"
x-model="form.characterName"
maxlength="50"
required
placeholder="p.ej. DarkMage"/>
</label>
<label>
<span class="text-sm text-muted">Qué vende</span>
<textarea x-model="form.itemsDescription"
rows="3"
maxlength="2000"
required
placeholder="p.ej. SS B-grade x1000, SPS A-grade x500..."></textarea>
</label>
</div>
<div class="flex-row gap-2" style="align-items: flex-end;">
<label style="flex: 0 0 auto;">
<span class="text-sm text-muted">Hora de inicio</span>
<input type="datetime-local"
x-model="form.startedAt"/>
</label>
<button type="submit"
class="primary"
:disabled="submitting">
<span x-show="!submitting">Agregar</span>
<span x-show="submitting">Guardando…</span>
</button>
</div>
<p x-show="error" class="text-error text-sm" x-text="error"></p>
</form>
</article>
<article>
<header class="flex-row" style="justify-content: space-between; align-items: center;">
<div>
<h2 style="margin: 0;">Personajes en venta</h2>
<p class="text-sm text-muted" x-text="sales.length + ' personaje' + (sales.length === 1 ? '' : 's')"></p>
</div>
<label class="flex-row gap-1" style="align-items: center; cursor: pointer;">
<input type="checkbox"
x-model="soundEnabled"
@change="localStorage.setItem('ventasSoundEnabled', soundEnabled ? '1' : '0')"/>
<span class="text-sm">🔔 Alerta sonora</span>
</label>
</header>
<div x-show="loading && sales.length === 0" class="text-center p-4">
<p class="text-sm text-muted">Cargando…</p>
</div>
<div x-show="sales.length === 0 && !loading" class="text-center p-4">
<p class="text-sm text-muted">No hay personajes en venta. Agregá uno arriba.</p>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<template x-for="sale in sortedSales" :key="sale.id">
<article :class="getCardClass(sale)">
<header>
<div class="flex-row" style="justify-content: space-between; align-items: center;">
<h3 style="margin: 0; font-size: 1rem;" x-text="sale.characterName"></h3>
<span class="badge"
:class="getBadgeClass(sale)"
x-text="getStatusLabel(sale)"></span>
</div>
</header>
<p class="text-sm" style="white-space: pre-wrap; margin: 0.5rem 0;" x-text="sale.itemsDescription"></p>
<div style="margin: 0.75rem 0; text-align: center;">
<p class="text-mono text-xl text-bold"
:class="getCountdownClass(sale)"
x-text="getCountdown(sale)"
style="margin: 0;"></p>
</div>
<footer class="text-xs text-muted">
<p style="margin: 0;">Empezó: <span x-text="fmtDate(sale.startedAt)"></span></p>
<p style="margin: 0.25rem 0 0;">Expira: <span x-text="fmtDate(sale.expiresAt)"></span></p>
</footer>
<button type="button"
class="secondary outline"
style="margin-top: 0.75rem; width: 100%;"
@click="deleteSale(sale.id)">
Eliminar
</button>
</article>
</template>
</div>
</article>
</main>
<footer class="container">
<p class="text-center text-xs text-muted">
Tiempo real · No se auto-eliminan al expirar
</p>
</footer>
<script src="/static/js/api.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('ventasSection', () => ({
sales: [],
loading: true,
submitting: false,
error: null,
soundEnabled: localStorage.getItem('ventasSoundEnabled') === '1',
beepFired: new Set(),
form: {
characterName: '',
itemsDescription: '',
startedAt: '',
},
get sortedSales() {
return [...this.sales].sort((a, b) => {
const aExpired = this.isExpired(a);
const bExpired = this.isExpired(b);
if (aExpired && !bExpired) return 1;
if (!aExpired && bExpired) return -1;
return new Date(b.expiresAt) - new Date(a.expiresAt);
});
},
async init() {
await this.loadSales();
setInterval(() => {
this.$refs;
this.sales = [...this.sales];
}, 1000);
},
async loadSales() {
try {
this.sales = await window.api.listSales();
} catch (e) {
this.error = e.message || 'Error cargando ventas';
} finally {
this.loading = false;
}
},
async submitSale() {
if (!this.form.characterName.trim() || !this.form.itemsDescription.trim()) {
this.error = 'Completá todos los campos';
return;
}
this.error = null;
this.submitting = true;
try {
let startedAt = null;
if (this.form.startedAt) {
startedAt = new Date(this.form.startedAt).toISOString();
}
const created = await window.api.createSale({
characterName: this.form.characterName.trim(),
itemsDescription: this.form.itemsDescription.trim(),
startedAt: startedAt,
});
this.sales.unshift(created);
this.form.characterName = '';
this.form.itemsDescription = '';
this.form.startedAt = '';
} catch (e) {
this.error = e.message || 'Error al guardar';
} finally {
this.submitting = false;
}
},
async deleteSale(id) {
if (!confirm('Eliminar este personaje?')) return;
try {
await window.api.deleteSale(id);
this.sales = this.sales.filter(s => s.id !== id);
} catch (e) {
this.error = e.message || 'Error al eliminar';
}
},
isExpired(sale) {
return new Date(sale.expiresAt).getTime() <= Date.now();
},
getRemaining(sale) {
return Math.max(0, new Date(sale.expiresAt).getTime() - Date.now());
},
getCountdown(sale) {
const ms = this.getRemaining(sale);
if (ms <= 0) return '00:00:00';
const totalSecs = Math.floor(ms / 1000);
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = totalSecs % 60;
return [h, m, s].map(v => String(v).padStart(2, '0')).join(':');
},
getCountdownClass(sale) {
const ms = this.getRemaining(sale);
if (ms <= 0) return 'text-muted';
const hours = ms / 1000 / 3600;
if (hours < 1) return 'text-error';
if (hours < 3) return 'text-warning';
return 'text-success';
},
getBadgeClass(sale) {
if (this.isExpired(sale)) return 'badge-muted';
const ms = this.getRemaining(sale);
const hours = ms / 1000 / 3600;
if (hours < 0.25) return 'badge-error';
if (hours < 1) return 'badge-warning';
return 'badge-success';
},
getStatusLabel(sale) {
if (this.isExpired(sale)) return 'Expirado';
const ms = this.getRemaining(sale);
const hours = ms / 1000 / 3600;
if (hours < 0.25) return 'Por expirar';
if (hours < 1) return 'Pocas horas';
return 'Activo';
},
getCardClass(sale) {
if (this.isExpired(sale)) return 'card-expired';
const ms = this.getRemaining(sale);
const hours = ms / 1000 / 3600;
if (hours < 0.25) return 'card-urgent';
return '';
},
fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('es-CL', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
},
}));
});
window.beep = () => {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = 880;
o.connect(ctx.destination);
o.start();
o.stop(ctx.currentTime + 0.25);
} catch (e) {}
};
</script>
</body>
</html>
@@ -0,0 +1,10 @@
<nav x-data="appTabBar()" class="container">
<ul class="tabs">
<li><a href="/insumos" :class="activeTab('/insumos')">Insumos</a></li>
<li><a href="/formulas" :class="activeTab('/formulas')">Formulas</a></li>
<li><a href="/calculadora" :class="activeTab('/calculadora')">Calculadora</a></li>
<li><a href="/historial" :class="activeTab('/historial')">Historial</a></li>
<li><a href="/ventas" :class="activeTab('/ventas')">Ventas</a></li>
<li x-show="user()?.isAdmin"><a href="/usuarios" :class="activeTab('/usuarios')">Usuarios</a></li>
</ul>
</nav>