feat: add personajes page with level tracking and fix timer reactivity
CI / Build Native (push) Failing after 58s
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:
@@ -0,0 +1,36 @@
|
||||
package com.l2.shots.characters;
|
||||
|
||||
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 = "characters", indexes = {
|
||||
@Index(name = "idx_characters_user", columnList = "user_id")
|
||||
})
|
||||
public class Character extends PanacheEntityBase {
|
||||
|
||||
@Id
|
||||
public UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
public UUID userId;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 50)
|
||||
public String name;
|
||||
|
||||
@Column(name = "character_class", nullable = false, length = 30)
|
||||
public String characterClass;
|
||||
|
||||
@Column(name = "level", nullable = false)
|
||||
public int level;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
public Instant createdAt;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.l2.shots.characters;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@RegisterForReflection
|
||||
public record CharacterDto(
|
||||
UUID id,
|
||||
String name,
|
||||
String characterClass,
|
||||
int level,
|
||||
Instant createdAt) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.l2.shots.characters;
|
||||
|
||||
public class CharacterHasSalesException extends RuntimeException {
|
||||
public CharacterHasSalesException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.l2.shots.characters;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
@RegisterForReflection
|
||||
public record CharacterIn(
|
||||
String name,
|
||||
String characterClass,
|
||||
int level) {
|
||||
|
||||
public CharacterIn {
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("name is required");
|
||||
}
|
||||
if (name.length() > 50) {
|
||||
throw new IllegalArgumentException("name must be 50 chars or less");
|
||||
}
|
||||
if (characterClass == null || characterClass.isBlank()) {
|
||||
throw new IllegalArgumentException("characterClass is required");
|
||||
}
|
||||
if (characterClass.length() > 30) {
|
||||
throw new IllegalArgumentException("characterClass must be 30 chars or less");
|
||||
}
|
||||
if (level <= 0) {
|
||||
throw new IllegalArgumentException("level must be greater than 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.l2.shots.characters;
|
||||
|
||||
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.PUT;
|
||||
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/characters")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class CharacterResource {
|
||||
|
||||
@Inject
|
||||
CharacterService characterService;
|
||||
|
||||
@Inject
|
||||
JwtCookieAuth jwtCookieAuth;
|
||||
|
||||
@GET
|
||||
public Response list(@Context HttpHeaders headers) {
|
||||
Optional<UUID> userId = extractUserId(headers);
|
||||
if (userId.isEmpty()) return Response.status(401).build();
|
||||
|
||||
List<CharacterDto> characters = characterService.listForUser(userId.get());
|
||||
return Response.ok(characters).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@Context HttpHeaders headers, CharacterIn 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 {
|
||||
CharacterIn validated = new CharacterIn(
|
||||
input.name(),
|
||||
input.characterClass(),
|
||||
input.level());
|
||||
CharacterDto created = characterService.create(userId.get(), validated);
|
||||
return Response.status(201).entity(created).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity("{\"error\":\"" + e.getMessage() + "\"}").build();
|
||||
}
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/level")
|
||||
public Response updateLevel(@Context HttpHeaders headers,
|
||||
@PathParam("id") String idStr,
|
||||
UpdateLevelRequest body) {
|
||||
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();
|
||||
}
|
||||
|
||||
if (body == null || body.level() <= 0) {
|
||||
return Response.status(400).entity("{\"error\":\"level debe ser mayor a 0\"}").build();
|
||||
}
|
||||
|
||||
Optional<CharacterDto> updated = characterService.updateLevel(userId.get(), id, body.level());
|
||||
return updated.map(c -> Response.ok(c).build())
|
||||
.orElse(Response.status(404).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();
|
||||
}
|
||||
|
||||
try {
|
||||
characterService.delete(userId.get(), id);
|
||||
return Response.noContent().build();
|
||||
} catch (CharacterHasSalesException e) {
|
||||
return Response.status(409).entity("{\"error\":\"" + e.getMessage() + "\"}").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();
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateLevelRequest(int level) {}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.l2.shots.characters;
|
||||
|
||||
import com.l2.shots.sales.CharacterSale;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class CharacterService {
|
||||
|
||||
public List<CharacterDto> listForUser(UUID userId) {
|
||||
return Character.<Character>list("userId = ?1 ORDER BY name ASC", userId)
|
||||
.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<CharacterDto> getById(UUID userId, UUID id) {
|
||||
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||
if (entity == null) return Optional.empty();
|
||||
return Optional.of(toDto(entity));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CharacterDto create(UUID userId, CharacterIn input) {
|
||||
Character entity = new Character();
|
||||
entity.id = UUID.randomUUID();
|
||||
entity.userId = userId;
|
||||
entity.name = input.name();
|
||||
entity.characterClass = input.characterClass();
|
||||
entity.level = input.level();
|
||||
entity.createdAt = Instant.now();
|
||||
entity.persist();
|
||||
return toDto(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<CharacterDto> updateLevel(UUID userId, UUID id, int newLevel) {
|
||||
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||
if (entity == null) return Optional.empty();
|
||||
entity.level = newLevel;
|
||||
return Optional.of(toDto(entity));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID userId, UUID id) {
|
||||
Character entity = Character.find("id = ?1 AND userId = ?2", id, userId).firstResult();
|
||||
if (entity == null) return;
|
||||
|
||||
boolean hasSales = CharacterSale.count("userId = ?1 AND characterName = ?2",
|
||||
userId, entity.name) > 0;
|
||||
if (hasSales) {
|
||||
throw new CharacterHasSalesException(
|
||||
"No se puede eliminar el personaje porque tiene ventas asociadas");
|
||||
}
|
||||
|
||||
entity.delete();
|
||||
}
|
||||
|
||||
private CharacterDto toDto(Character entity) {
|
||||
return new CharacterDto(
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.characterClass,
|
||||
entity.level,
|
||||
entity.createdAt);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public class PageResource {
|
||||
public static native TemplateInstance historial();
|
||||
public static native TemplateInstance usuarios();
|
||||
public static native TemplateInstance ventas();
|
||||
public static native TemplateInstance personajes();
|
||||
}
|
||||
|
||||
private Optional<AuthMeResponse> currentUser(HttpHeaders headers) {
|
||||
@@ -170,4 +171,17 @@ public class PageResource {
|
||||
}
|
||||
return Response.ok(Pages.ventas().render()).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/personajes")
|
||||
public Response personajes(@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.personajes().render()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}, 1000);
|
||||
this.timer = setTimeout(() => this.tick(), 1000);
|
||||
},
|
||||
|
||||
visibilityHandler() {
|
||||
if (!document.hidden) {
|
||||
this.sales = [...this.sales];
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user