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-muted { color: var(--pico-muted-color); }
|
||||||
.text-success { color: #059669; }
|
.text-success { color: #059669; }
|
||||||
.text-error { color: #dc2626; }
|
.text-error { color: #dc2626; }
|
||||||
|
.text-warning { color: #d97706; }
|
||||||
.text-bold { font-weight: 600; }
|
.text-bold { font-weight: 600; }
|
||||||
|
|
||||||
.flex-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
.flex-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
@@ -357,6 +358,7 @@ header {
|
|||||||
.badge-warning { background: #fef3c7; color: #92400e; }
|
.badge-warning { background: #fef3c7; color: #92400e; }
|
||||||
.badge-success { background: #d1fae5; color: #065f46; }
|
.badge-success { background: #d1fae5; color: #065f46; }
|
||||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-muted { background: #f3f4f6; color: #6b7280; }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TABS
|
TABS
|
||||||
@@ -605,6 +607,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-md { font-size: 1rem; }
|
.text-md { font-size: 1rem; }
|
||||||
|
.text-lg { font-size: 1.125rem; }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
HISTORIAL — BADGE RUN
|
HISTORIAL — BADGE RUN
|
||||||
@@ -619,6 +622,8 @@ header {
|
|||||||
|
|
||||||
.badge-profit { background: #d1fae5; color: #065f46; }
|
.badge-profit { background: #d1fae5; color: #065f46; }
|
||||||
.badge-loss { background: #fee2e2; color: #991b1b; }
|
.badge-loss { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-craft { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-buy { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
HISTORIAL — SNAPSHOT SECTION
|
HISTORIAL — SNAPSHOT SECTION
|
||||||
@@ -674,3 +679,15 @@ header {
|
|||||||
.gap-2 { gap: 0.5rem; }
|
.gap-2 { gap: 0.5rem; }
|
||||||
.gap-4 { gap: 1rem; }
|
.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