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,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>
|
||||
Reference in New Issue
Block a user