feat(tickets): implement tickets page with ticket component and service
This commit is contained in:
@@ -574,24 +574,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="component-demo card shadow-sm mt-4">
|
|
||||||
<div class="card-body">
|
|
||||||
<span class="eyebrow">Reusable Component</span>
|
|
||||||
<h2 class="mb-4">Ticket</h2>
|
|
||||||
|
|
||||||
<p class="text-muted mb-4">Ticket responsive que ocupa el 100% del ancho disponible.</p>
|
|
||||||
|
|
||||||
<app-ticket
|
|
||||||
title="ENTRADA GENERAL"
|
|
||||||
ticketId="0000000000"
|
|
||||||
date="09 de Octubre"
|
|
||||||
(viewQr)="triggerToast('info')"
|
|
||||||
(download)="triggerToast('success')"
|
|
||||||
(share)="triggerToast('info')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="component-demo card shadow-sm mt-4">
|
<section class="component-demo card shadow-sm mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="eyebrow">Reusable Component</span>
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { TenantService } from '../../../../core/services/tenant.service';
|
|||||||
import { ToastService } from '../../../../core/services/toast.service';
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||||
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
||||||
import { TicketComponent } from '../../../../shared/components/ticket/ticket.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-reutilizables-test-page',
|
selector: 'app-reutilizables-test-page',
|
||||||
@@ -31,7 +30,6 @@ import { TicketComponent } from '../../../../shared/components/ticket/ticket.com
|
|||||||
CartIconComponent,
|
CartIconComponent,
|
||||||
StepperComponent,
|
StepperComponent,
|
||||||
StepComponent,
|
StepComponent,
|
||||||
TicketComponent,
|
|
||||||
],
|
],
|
||||||
templateUrl: './reutilizables-test-page.component.html',
|
templateUrl: './reutilizables-test-page.component.html',
|
||||||
styleUrl: './reutilizables-test-page.component.scss',
|
styleUrl: './reutilizables-test-page.component.scss',
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<div class="sidebar-container">
|
<div class="sidebar-container">
|
||||||
<h2 class="sidebar-title">MI CUENTA</h2>
|
<h2 class="sidebar-title">MI CUENTA</h2>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link">Datos Personales</a>
|
<a routerLink="/mi-cuenta/datos-personales" routerLinkActive="active" class="nav-link"
|
||||||
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
|
>Datos Personales</a
|
||||||
</nav>
|
>
|
||||||
</div>
|
<a routerLink="/mi-cuenta/compras" routerLinkActive="active" class="nav-link">Mis compras</a>
|
||||||
|
<a routerLink="/mi-cuenta/tickets" routerLinkActive="active" class="nav-link">Mis tickets</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
<app-icon-button variant="download" ariaLabel="Descargar ticket" (clicked)="download.emit()" />
|
<app-icon-button variant="download" ariaLabel="Descargar ticket" (clicked)="download.emit()" />
|
||||||
|
|
||||||
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -5,13 +5,12 @@
|
|||||||
|
|
||||||
.ticket {
|
.ticket {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
grid-template-columns: auto minmax(0, 1fr) 78px auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 56px;
|
min-height: 76px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
background: transparent;
|
|
||||||
color: #666666;
|
color: #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +21,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ticket__selection input {
|
.ticket__selection input {
|
||||||
width: 16px;
|
width: 21px;
|
||||||
height: 16px;
|
height: 21px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
accent-color: var(--tenant-primary, #009933);
|
accent-color: var(--tenant-primary, #009933);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -45,34 +44,28 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: #666666;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket__id {
|
.ticket__id {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #666666;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket__date {
|
.ticket__date {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #666666;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket__actions {
|
.ticket__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep .ticket__qr-button {
|
:host ::ng-deep .ticket__qr-button {
|
||||||
min-width: auto;
|
min-width: 115px;
|
||||||
min-height: 36px;
|
min-height: 38px;
|
||||||
padding-inline: 14px;
|
padding-inline: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,20 +74,22 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.ticket {
|
.ticket {
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 20px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket__date {
|
.ticket__date {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket__actions {
|
.ticket__actions {
|
||||||
grid-column: 3;
|
grid-column: 3;
|
||||||
grid-row: 1 / span 2;
|
grid-row: 1 / span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .ticket__qr-button {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TicketComponent } from './ticket.component';
|
import { TicketComponent } from './ticket.component';
|
||||||
|
|
||||||
describe('TicketComponent', () => {
|
describe('TicketComponent', () => {
|
||||||
it('renders the ticket data and emits every action', async () => {
|
it('renders its data and emits its actions', async () => {
|
||||||
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
|
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(TicketComponent);
|
const fixture = TestBed.createComponent(TicketComponent);
|
||||||
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
|
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
|
||||||
fixture.componentRef.setInput('ticketId', '0000000000');
|
fixture.componentRef.setInput('ticketId', '0000000000');
|
||||||
fixture.componentRef.setInput('date', '09 de Octubre');
|
fixture.componentRef.setInput('date', '09 de octubre');
|
||||||
|
|
||||||
const viewQr = vi.fn();
|
const viewQr = vi.fn();
|
||||||
const download = vi.fn();
|
const download = vi.fn();
|
||||||
@@ -22,7 +23,6 @@ describe('TicketComponent', () => {
|
|||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
expect(element.textContent).toContain('ENTRADA GENERAL');
|
expect(element.textContent).toContain('ENTRADA GENERAL');
|
||||||
expect(element.textContent).toContain('ID: 0000000000');
|
expect(element.textContent).toContain('ID: 0000000000');
|
||||||
expect(element.textContent).toContain('09 de Octubre');
|
|
||||||
|
|
||||||
element.querySelector<HTMLButtonElement>('app-button button')?.click();
|
element.querySelector<HTMLButtonElement>('app-button button')?.click();
|
||||||
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
|
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
|
||||||
@@ -32,19 +32,4 @@ describe('TicketComponent', () => {
|
|||||||
expect(download).toHaveBeenCalledOnce();
|
expect(download).toHaveBeenCalledOnce();
|
||||||
expect(share).toHaveBeenCalledOnce();
|
expect(share).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports two-way selection state', async () => {
|
|
||||||
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(TicketComponent);
|
|
||||||
fixture.componentRef.setInput('ticketId', '0000000000');
|
|
||||||
fixture.componentRef.setInput('date', '09 de Octubre');
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const checkbox = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
|
||||||
checkbox.click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.selected()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
|
||||||
import { ButtonComponent } from '../button/button.component';
|
|
||||||
import { IconButtonComponent } from '../icon-button/icon-button.component';
|
import { ButtonComponent } from '../../../../../../../../shared/components/button/button.component';
|
||||||
|
import { IconButtonComponent } from '../../../../../../../../shared/components/icon-button/icon-button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-ticket',
|
selector: 'app-ticket',
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { TenantService } from '../../../../../../core/services/tenant.service';
|
||||||
|
|
||||||
|
export interface TicketResponse {
|
||||||
|
id: number;
|
||||||
|
tenant_code: string;
|
||||||
|
ticket: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
source_catalog_item_id: number | null;
|
||||||
|
source_variant_id: number | null;
|
||||||
|
starts_at: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
used_at: string | null;
|
||||||
|
is_valid: boolean;
|
||||||
|
is_expired: boolean;
|
||||||
|
is_used: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TicketService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
getTickets(): Promise<TicketResponse[]> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
|
||||||
|
).then((response) => response.data ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<section class="tickets-page">
|
||||||
|
<h1 class="tickets-page__title">MIS TICKETS</h1>
|
||||||
|
|
||||||
|
<div class="tickets-list" aria-busy="{{ isLoading() }}">
|
||||||
|
<header class="tickets-list__header">
|
||||||
|
<label class="tickets-list__select-all">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="allSelected()"
|
||||||
|
[disabled]="tickets().length === 0"
|
||||||
|
(change)="toggleAll($any($event.target).checked)"
|
||||||
|
/>
|
||||||
|
<span>TICKETS ACTIVOS</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="tickets-list__bulk-actions">
|
||||||
|
<span class="tickets-list__bulk-action">
|
||||||
|
<app-icon-button variant="download" ariaLabel="Descargar tickets seleccionados" />
|
||||||
|
<small>Descargar</small>
|
||||||
|
</span>
|
||||||
|
<span class="tickets-list__bulk-action">
|
||||||
|
<app-icon-button variant="share" ariaLabel="Compartir tickets seleccionados" />
|
||||||
|
<small>Compartir</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (isLoading()) {
|
||||||
|
@for (item of [1, 2, 3, 4]; track item) {
|
||||||
|
<div class="tickets-list__skeleton" aria-hidden="true"></div>
|
||||||
|
}
|
||||||
|
} @else if (tickets().length) {
|
||||||
|
@for (ticket of tickets(); track ticket.id) {
|
||||||
|
<app-ticket
|
||||||
|
[title]="ticket.name"
|
||||||
|
[ticketId]="ticket.ticket"
|
||||||
|
[date]="formatDate(ticket)"
|
||||||
|
[selected]="isSelected(ticket.id)"
|
||||||
|
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
.tickets-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-page__title {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__select-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: #a0a0a0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__select-all input {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--tenant-primary, #009933);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__bulk-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list app-ticket {
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__empty {
|
||||||
|
padding: 32px 0;
|
||||||
|
color: #666666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tickets-list__skeleton {
|
||||||
|
height: 77px;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
background: linear-gradient(90deg, transparent, #f3f3f3, transparent);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.tickets-page__title {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||||
|
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
||||||
|
import { TicketComponent } from './components/ticket/ticket.component';
|
||||||
|
import { TicketResponse, TicketService } from './ticket.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tickets-page',
|
||||||
|
imports: [IconButtonComponent, TicketComponent],
|
||||||
|
providers: [TicketService],
|
||||||
|
templateUrl: './tickets-page.html',
|
||||||
|
styleUrl: './tickets-page.scss',
|
||||||
|
})
|
||||||
|
export class TicketsPage implements OnInit {
|
||||||
|
private readonly ticketService = inject(TicketService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
|
|
||||||
|
protected readonly tickets = signal<TicketResponse[]>([]);
|
||||||
|
protected readonly selectedIds = signal<Set<number>>(new Set());
|
||||||
|
protected readonly isLoading = signal(true);
|
||||||
|
protected readonly allSelected = computed(
|
||||||
|
() => this.tickets().length > 0 && this.selectedIds().size === this.tickets().length,
|
||||||
|
);
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.tickets.set(await this.ticketService.getTickets());
|
||||||
|
} catch {
|
||||||
|
this.toastService.danger('Hubo un error al cargar los tickets');
|
||||||
|
} finally {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleAll(selected: boolean): void {
|
||||||
|
this.selectedIds.set(selected ? new Set(this.tickets().map((ticket) => ticket.id)) : new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleTicket(ticketId: number, selected: boolean): void {
|
||||||
|
this.selectedIds.update((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
selected ? next.add(ticketId) : next.delete(ticketId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isSelected(ticketId: number): boolean {
|
||||||
|
return this.selectedIds().has(ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatDate(ticket: TicketResponse): string | null {
|
||||||
|
const value = ticket.starts_at ?? ticket.expires_at;
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('es-AR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,14 @@ export const routes: Routes = [
|
|||||||
(m) => m.PurchaseDetailPage,
|
(m) => m.PurchaseDetailPage,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tickets',
|
||||||
|
canActivate: [hasMenuGuard('tickets')],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/account-page/pages/tickets-page/tickets-page').then(
|
||||||
|
(m) => m.TicketsPage,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'datos-personales',
|
redirectTo: 'datos-personales',
|
||||||
|
|||||||
Reference in New Issue
Block a user