feat: add personajes page with level tracking and fix timer reactivity
CI / Build Native (push) Failing after 58s

- New /personajes page with CRUD for characters (name, class, level)
- Level editable inline, deletable (blocked if has active sales)
- ventas.html now loads characters list as datalist suggestions
- Fix timer: add reactive 'now' property so countdown updates
- Fix script loading: add defaults.js + app.js to ventas/personajes
- Fix table auto-reload: use loadCharacters()/loadSales() after mutations
- Timer now uses setTimeout recursion + visibilitychange handler
This commit is contained in:
2026-08-18 10:09:10 -04:00
parent af323da3d3
commit 661ac4d953
12 changed files with 574 additions and 10 deletions
@@ -124,4 +124,26 @@ window.api = {
deleteSale(id) {
return this.request(`/api/sales/${id}`, { method: 'DELETE' });
},
listCharacters() {
return this.request('/api/characters');
},
createCharacter(payload) {
return this.request('/api/characters', {
method: 'POST',
body: JSON.stringify(payload),
});
},
updateCharacterLevel(id, level) {
return this.request(`/api/characters/${id}/level`, {
method: 'PUT',
body: JSON.stringify({ level }),
});
},
deleteCharacter(id) {
return this.request(`/api/characters/${id}`, { method: 'DELETE' });
},
};
@@ -0,0 +1,10 @@
CREATE TABLE characters (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
name VARCHAR(50) NOT NULL,
character_class VARCHAR(30) NOT NULL,
level INT NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_characters_user ON characters (user_id);
@@ -0,0 +1,207 @@
<!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>Personajes — 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="personajesSection()">
<article>
<header>
<h2>Agregar personaje</h2>
</header>
<form @submit.prevent="submitCharacter()">
<div class="grid" style="grid-template-columns: 1fr 1fr 100px;">
<label>
<span class="text-sm text-muted">Nombre</span>
<input type="text"
x-model="form.name"
maxlength="50"
required
placeholder="p.ej. DarkMage"/>
</label>
<label>
<span class="text-sm text-muted">Clase</span>
<input type="text"
x-model="form.characterClass"
maxlength="30"
required
placeholder="p.ej. DC, HE, SWS, EE"/>
</label>
<label>
<span class="text-sm text-muted">Nivel</span>
<input type="number"
x-model.number="form.level"
min="1"
max="100"
required
placeholder="85"/>
</label>
</div>
<div style="margin-top: 0.75rem;">
<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>
<h2 style="margin: 0;">Mis personajes</h2>
<p class="text-sm text-muted" x-text="characters.length + ' personaje' + (characters.length === 1 ? '' : 's')"></p>
</header>
<div x-show="loading && characters.length === 0" class="text-center p-4">
<p class="text-sm text-muted">Cargando…</p>
</div>
<div x-show="characters.length === 0 && !loading" class="text-center p-4">
<p class="text-sm text-muted">No hay personajes. Agregá uno arriba.</p>
</div>
<div x-show="characters.length > 0" class="table-container">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Clase</th>
<th>Nivel</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<template x-for="c in characters" :key="c.id">
<tr>
<td x-text="c.name"></td>
<td x-text="c.characterClass"></td>
<td>
<div class="flex-row gap-1" style="align-items: center;">
<input type="number"
min="1"
max="100"
x-model.number="c.level"
@keydown.enter="updateLevel(c)"
@blur="updateLevel(c)"
style="width: 70px; padding: 0.25rem 0.5rem; font-size: 0.875rem;"/>
</div>
</td>
<td>
<button type="button"
class="secondary outline"
style="font-size: 0.75rem; padding: 0.25rem 0.5rem;"
@click="deleteCharacter(c.id, c.name)">
Eliminar
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</article>
</main>
<footer class="container">
<p class="text-center text-xs text-muted">
Hacé clic en el nivel para editarlo inline · Enter o perder foco guarda
</p>
</footer>
<script src="/static/js/defaults.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('personajesSection', () => ({
characters: [],
loading: true,
submitting: false,
error: null,
form: {
name: '',
characterClass: '',
level: null,
},
async init() {
await this.loadCharacters();
},
async loadCharacters() {
try {
this.characters = await window.api.listCharacters();
} catch (e) {
this.error = e.message || 'Error cargando personajes';
} finally {
this.loading = false;
}
},
async submitCharacter() {
if (!this.form.name.trim() || !this.form.characterClass.trim() || !this.form.level) {
this.error = 'Completá todos los campos';
return;
}
this.error = null;
this.submitting = true;
try {
await window.api.createCharacter({
name: this.form.name.trim(),
characterClass: this.form.characterClass.trim(),
level: this.form.level,
});
this.form.name = '';
this.form.characterClass = '';
this.form.level = null;
await this.loadCharacters();
} catch (e) {
this.error = e.message || 'Error al guardar';
} finally {
this.submitting = false;
}
},
async updateLevel(c) {
if (c.level <= 0 || c.level > 100) {
c.level = Math.min(100, Math.max(1, c.level));
}
try {
await window.api.updateCharacterLevel(c.id, c.level);
} catch (e) {
this.error = e.message || 'Error al actualizar nivel';
}
},
async deleteCharacter(id, name) {
if (!confirm('Eliminar el personaje "' + name + '"?')) return;
try {
await window.api.deleteCharacter(id);
await this.loadCharacters();
} catch (e) {
alert(e.message || 'Error al eliminar personaje');
}
},
}));
});
</script>
</body>
</html>
@@ -28,8 +28,14 @@
x-model="form.characterName"
maxlength="50"
required
placeholder="p.ej. DarkMage"/>
list="character-suggestions"
placeholder="Seleccioná o escribí un nombre"/>
</label>
<datalist id="character-suggestions">
<template x-for="c in characters" :key="c.id">
<option :value="c.name"></option>
</template>
</datalist>
<label>
<span class="text-sm text-muted">Qué vende</span>
<textarea x-model="form.itemsDescription"
@@ -119,18 +125,23 @@
</p>
</footer>
<script src="/static/js/defaults.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('ventasSection', () => ({
sales: [],
characters: [],
loading: true,
submitting: false,
error: null,
soundEnabled: localStorage.getItem('ventasSoundEnabled') === '1',
beepFired: new Set(),
timer: null,
now: Date.now(),
form: {
characterName: '',
@@ -149,11 +160,26 @@
},
async init() {
await this.loadSales();
setInterval(() => {
this.$refs;
await Promise.all([this.loadSales(), this.loadCharacters()]);
document.addEventListener('visibilitychange', this.visibilityHandler);
this.tick();
},
tick() {
this.now = Date.now();
this.sales = [...this.sales];
this.timer = setTimeout(() => this.tick(), 1000);
},
visibilityHandler() {
if (!document.hidden) {
this.sales = [...this.sales];
}, 1000);
}
},
destroy() {
document.removeEventListener('visibilitychange', this.visibilityHandler);
if (this.timer) clearTimeout(this.timer);
},
async loadSales() {
@@ -166,6 +192,13 @@
}
},
async loadCharacters() {
try {
this.characters = await window.api.listCharacters();
} catch (e) {
}
},
async submitSale() {
if (!this.form.characterName.trim() || !this.form.itemsDescription.trim()) {
this.error = 'Completá todos los campos';
@@ -178,15 +211,15 @@
if (this.form.startedAt) {
startedAt = new Date(this.form.startedAt).toISOString();
}
const created = await window.api.createSale({
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 = '';
await this.loadSales();
} catch (e) {
this.error = e.message || 'Error al guardar';
} finally {
@@ -198,18 +231,18 @@
if (!confirm('Eliminar este personaje?')) return;
try {
await window.api.deleteSale(id);
this.sales = this.sales.filter(s => s.id !== id);
await this.loadSales();
} catch (e) {
this.error = e.message || 'Error al eliminar';
}
},
isExpired(sale) {
return new Date(sale.expiresAt).getTime() <= Date.now();
return new Date(sale.expiresAt).getTime() <= this.now;
},
getRemaining(sale) {
return Math.max(0, new Date(sale.expiresAt).getTime() - Date.now());
return Math.max(0, new Date(sale.expiresAt).getTime() - this.now);
},
getCountdown(sale) {
@@ -5,6 +5,7 @@
<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><a href="/personajes" :class="activeTab('/personajes')">Personajes</a></li>
<li x-show="user()?.isAdmin"><a href="/usuarios" :class="activeTab('/usuarios')">Usuarios</a></li>
</ul>
</nav>