feat: add character sales tracking with 12h countdown timer
CI / Build Native (push) Failing after 1m11s
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:
@@ -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>
|
||||
Reference in New Issue
Block a user