diff --git a/src/main/java/com/l2/shots/sales/CharacterSale.java b/src/main/java/com/l2/shots/sales/CharacterSale.java new file mode 100644 index 0000000..baeb9d6 --- /dev/null +++ b/src/main/java/com/l2/shots/sales/CharacterSale.java @@ -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; +} diff --git a/src/main/java/com/l2/shots/sales/CharacterSaleDto.java b/src/main/java/com/l2/shots/sales/CharacterSaleDto.java new file mode 100644 index 0000000..421a92e --- /dev/null +++ b/src/main/java/com/l2/shots/sales/CharacterSaleDto.java @@ -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) { +} diff --git a/src/main/java/com/l2/shots/sales/CharacterSaleIn.java b/src/main/java/com/l2/shots/sales/CharacterSaleIn.java new file mode 100644 index 0000000..4333221 --- /dev/null +++ b/src/main/java/com/l2/shots/sales/CharacterSaleIn.java @@ -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"); + } + } +} diff --git a/src/main/java/com/l2/shots/sales/CharacterSaleResource.java b/src/main/java/com/l2/shots/sales/CharacterSaleResource.java new file mode 100644 index 0000000..63c1fcb --- /dev/null +++ b/src/main/java/com/l2/shots/sales/CharacterSaleResource.java @@ -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 userId = extractUserId(headers); + if (userId.isEmpty()) return Response.status(401).build(); + + List sales = saleService.listForUser(userId.get()); + return Response.ok(sales).build(); + } + + @POST + public Response create(@Context HttpHeaders headers, CharacterSaleIn input) { + Optional 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 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 extractUserId(HttpHeaders headers) { + Optional 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(); + } + } +} diff --git a/src/main/java/com/l2/shots/sales/CharacterSaleService.java b/src/main/java/com/l2/shots/sales/CharacterSaleService.java new file mode 100644 index 0000000..0e17c29 --- /dev/null +++ b/src/main/java/com/l2/shots/sales/CharacterSaleService.java @@ -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 listForUser(UUID userId) { + return CharacterSale.list("userId = ?1 ORDER BY createdAt DESC", userId) + .stream() + .map(this::toDto) + .toList(); + } + + public Optional 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); + } +} diff --git a/src/main/java/com/l2/shots/ui/PageResource.java b/src/main/java/com/l2/shots/ui/PageResource.java new file mode 100644 index 0000000..45ff46d --- /dev/null +++ b/src/main/java/com/l2/shots/ui/PageResource.java @@ -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 currentUser(HttpHeaders headers) { + return templateData.currentUser(headers); + } + + @GET + public Response root(@Context HttpHeaders headers) { + Optional 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 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 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 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 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 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 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 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 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 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(); + } +} diff --git a/src/main/resources/META-INF/resources/static/css/app.css b/src/main/resources/META-INF/resources/static/css/app.css index 4485bdb..c3293c4 100644 --- a/src/main/resources/META-INF/resources/static/css/app.css +++ b/src/main/resources/META-INF/resources/static/css/app.css @@ -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; } + diff --git a/src/main/resources/META-INF/resources/static/js/api.js b/src/main/resources/META-INF/resources/static/js/api.js new file mode 100644 index 0000000..43c5757 --- /dev/null +++ b/src/main/resources/META-INF/resources/static/js/api.js @@ -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' }); + }, +}; diff --git a/src/main/resources/db/migration/V2__character_sales.sql b/src/main/resources/db/migration/V2__character_sales.sql new file mode 100644 index 0000000..151478f --- /dev/null +++ b/src/main/resources/db/migration/V2__character_sales.sql @@ -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); diff --git a/src/main/resources/templates/PageResource/ventas.html b/src/main/resources/templates/PageResource/ventas.html new file mode 100644 index 0000000..572d6c2 --- /dev/null +++ b/src/main/resources/templates/PageResource/ventas.html @@ -0,0 +1,285 @@ + + + + + + + Ventas — Calculadora de Craft de Shots + + + + + + {#include partials/header.html /} + {#include partials/tab-bar.html /} + +
+ +
+
+

Agregar personaje en venta

+

Duración por defecto: 12 horas

+
+
+
+ + +
+
+ + +
+

+
+
+ +
+
+
+

Personajes en venta

+

+
+ +
+ +
+

Cargando…

+
+ +
+

No hay personajes en venta. Agregá uno arriba.

+
+ +
+ +
+
+ +
+ +
+

+ Tiempo real · No se auto-eliminan al expirar +

+
+ + + + + + + + diff --git a/src/main/resources/templates/partials/tab-bar.html b/src/main/resources/templates/partials/tab-bar.html new file mode 100644 index 0000000..3b8a0cd --- /dev/null +++ b/src/main/resources/templates/partials/tab-bar.html @@ -0,0 +1,10 @@ +