Compare commits
4 Commits
bc37a91c7f
...
472dead0ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 472dead0ff | |||
| fa8032c557 | |||
| 6c5e10feb2 | |||
| 63541a3593 |
@@ -1,29 +1,49 @@
|
||||
<article class="ticket">
|
||||
<label class="ticket__selection">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selected()"
|
||||
(change)="selected.set($any($event.target).checked)"
|
||||
/>
|
||||
<span class="visually-hidden">Seleccionar ticket {{ title() }}</span>
|
||||
</label>
|
||||
<article class="ticket" [class.ticket--disabled]="disabled()" [attr.aria-disabled]="disabled()">
|
||||
@if (!disabled()) {
|
||||
<label class="ticket__selection">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selected()"
|
||||
(change)="selected.set($any($event.target).checked)"
|
||||
/>
|
||||
<span class="visually-hidden">Seleccionar ticket {{ title() }}</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
<div class="ticket__details">
|
||||
<h3 class="ticket__title">{{ title() }}</h3>
|
||||
<p class="ticket__id">ID: {{ ticketId() }}</p>
|
||||
</div>
|
||||
|
||||
@if (date(); as ticketDate) {
|
||||
<time class="ticket__date">{{ ticketDate }}</time>
|
||||
@if (date() || expired()) {
|
||||
<div class="ticket__status">
|
||||
@if (expired()) {
|
||||
<span class="ticket__expired">VENCIDO</span>
|
||||
}
|
||||
@if (date(); as ticketDate) {
|
||||
<time class="ticket__date">{{ ticketDate }}</time>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ticket__actions">
|
||||
<app-button variant="primary" buttonClass="ticket__qr-button" (click)="viewQr.emit()">
|
||||
<app-button
|
||||
variant="primary"
|
||||
buttonClass="ticket__qr-button"
|
||||
[disabled]="disabled()"
|
||||
(click)="viewQr.emit()"
|
||||
>
|
||||
<i class="fa-solid fa-qrcode" aria-hidden="true"></i>
|
||||
<span>Ver QR</span>
|
||||
</app-button>
|
||||
|
||||
<app-icon-button variant="download" ariaLabel="Descargar ticket" (clicked)="download.emit()" />
|
||||
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
||||
@if (!disabled()) {
|
||||
<app-icon-button
|
||||
variant="download"
|
||||
ariaLabel="Descargar ticket"
|
||||
(clicked)="download.emit()"
|
||||
/>
|
||||
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -44,31 +44,60 @@
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ticket__id {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ticket__date {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket__status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ticket__expired {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ticket__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket__qr-button {
|
||||
min-width: 115px;
|
||||
min-height: 38px;
|
||||
:host ::ng-deep button.ticket__qr-button {
|
||||
width: 115px;
|
||||
min-width: 0;
|
||||
max-width: 115px;
|
||||
min-height: 36px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.ticket--disabled {
|
||||
grid-template-columns: minmax(0, 1fr) 78px auto;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled {
|
||||
--bs-btn-disabled-bg: #8a8a8a;
|
||||
--bs-btn-disabled-border-color: #8a8a8a;
|
||||
--bs-btn-disabled-color: #ffffff;
|
||||
--bs-btn-disabled-opacity: 1;
|
||||
}
|
||||
|
||||
.ticket__selection input:focus-visible {
|
||||
outline: 2px solid var(--tenant-primary, #009933);
|
||||
outline-offset: 2px;
|
||||
@@ -80,7 +109,7 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ticket__date {
|
||||
.ticket__status {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
@@ -89,7 +118,15 @@
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ticket__qr-button {
|
||||
min-width: auto;
|
||||
.ticket--disabled {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.ticket--disabled .ticket__status {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.ticket--disabled .ticket__actions {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,33 @@ describe('TicketComponent', () => {
|
||||
expect(download).toHaveBeenCalledOnce();
|
||||
expect(share).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders the expired disabled state without secondary actions', 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.componentRef.setInput('disabled', true);
|
||||
fixture.componentRef.setInput('expired', true);
|
||||
|
||||
const viewQr = vi.fn();
|
||||
fixture.componentInstance.viewQr.subscribe(viewQr);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const ticket = element.querySelector<HTMLElement>('.ticket');
|
||||
const qrButton = element.querySelector<HTMLButtonElement>('app-button button');
|
||||
|
||||
expect(ticket?.classList.contains('ticket--disabled')).toBe(true);
|
||||
expect(ticket?.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(element.textContent).toContain('VENCIDO');
|
||||
expect(qrButton?.disabled).toBe(true);
|
||||
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
|
||||
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
|
||||
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
|
||||
|
||||
qrButton?.click();
|
||||
expect(viewQr).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ import { IconButtonComponent } from '../../../../../../../../shared/components/i
|
||||
})
|
||||
export class TicketComponent {
|
||||
readonly title = input('ENTRADA GENERAL');
|
||||
readonly ticketId = input.required<string>();
|
||||
readonly ticketId = input.required<string | number>();
|
||||
readonly date = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
readonly expired = input(false);
|
||||
readonly selected = model(false);
|
||||
|
||||
readonly viewQr = output<void>();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected()"
|
||||
[disabled]="tickets().length === 0"
|
||||
[disabled]="activeTickets().length === 0"
|
||||
(change)="toggleAll($any($event.target).checked)"
|
||||
/>
|
||||
<span>TICKETS ACTIVOS</span>
|
||||
@@ -30,15 +30,33 @@
|
||||
<div class="tickets-list__skeleton" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (tickets().length) {
|
||||
@for (ticket of tickets(); track ticket.id) {
|
||||
@for (ticket of activeTickets(); track ticket.id) {
|
||||
<app-ticket
|
||||
[title]="ticket.name"
|
||||
[ticketId]="ticket.ticket"
|
||||
[ticketId]="ticket.id"
|
||||
[date]="formatDate(ticket)"
|
||||
[selected]="isSelected(ticket.id)"
|
||||
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (inactiveTickets().length) {
|
||||
<section class="tickets-list__inactive" aria-labelledby="inactive-tickets-title">
|
||||
<header class="tickets-list__header tickets-list__header--inactive">
|
||||
<h2 id="inactive-tickets-title">TICKETS INACTIVOS</h2>
|
||||
</header>
|
||||
|
||||
@for (ticket of inactiveTickets(); track ticket.id) {
|
||||
<app-ticket
|
||||
[title]="ticket.name"
|
||||
[ticketId]="ticket.id"
|
||||
[date]="formatDate(ticket)"
|
||||
[disabled]="true"
|
||||
[expired]="ticket.is_expired"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
} @else {
|
||||
<p class="tickets-list__empty">Aún no tenés tickets disponibles.</p>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,21 @@
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.tickets-list__header--inactive {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tickets-list__header--inactive h2 {
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tickets-list__inactive {
|
||||
margin-top: 44px;
|
||||
}
|
||||
|
||||
.tickets-list__select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||
import { TicketComponent } from './components/ticket/ticket.component';
|
||||
import { TicketResponse, TicketService } from './ticket.service';
|
||||
import { TicketsPage } from './tickets-page';
|
||||
|
||||
const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
|
||||
id: 1,
|
||||
tenant_code: 'tenant',
|
||||
ticket: 'ticket-1',
|
||||
name: 'Entrada general',
|
||||
description: null,
|
||||
source_catalog_item_id: null,
|
||||
source_variant_id: null,
|
||||
starts_at: '2026-10-09T00:00:00.000Z',
|
||||
expires_at: null,
|
||||
used_at: null,
|
||||
is_valid: true,
|
||||
is_expired: false,
|
||||
is_used: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('TicketsPage', () => {
|
||||
it('separates expired and used tickets into the disabled inactive list', async () => {
|
||||
const tickets = [
|
||||
ticket({ id: 1 }),
|
||||
ticket({ id: 2, is_valid: false, is_expired: true }),
|
||||
ticket({ id: 3, is_valid: false, is_used: true, used_at: '2026-10-10T00:00:00.000Z' }),
|
||||
];
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TicketsPage],
|
||||
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
|
||||
})
|
||||
.overrideComponent(TicketsPage, {
|
||||
set: {
|
||||
providers: [
|
||||
{ provide: TicketService, useValue: { getTickets: () => Promise.resolve(tickets) } },
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TicketsPage);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const ticketComponents = fixture.debugElement.queryAll(By.directive(TicketComponent));
|
||||
const [activeTicket, expiredTicket, usedTicket] = ticketComponents.map(
|
||||
(element) => element.componentInstance as TicketComponent,
|
||||
);
|
||||
|
||||
expect(ticketComponents).toHaveLength(3);
|
||||
expect(activeTicket.disabled()).toBe(false);
|
||||
expect(expiredTicket.disabled()).toBe(true);
|
||||
expect(expiredTicket.expired()).toBe(true);
|
||||
expect(usedTicket.disabled()).toBe(true);
|
||||
expect(usedTicket.expired()).toBe(false);
|
||||
expect(fixture.nativeElement.textContent).toContain('TICKETS INACTIVOS');
|
||||
|
||||
const page = fixture.nativeElement as HTMLElement;
|
||||
const inactiveElements = Array.from(
|
||||
page.querySelectorAll<HTMLElement>('.tickets-list__inactive app-ticket'),
|
||||
);
|
||||
expect(inactiveElements).toHaveLength(2);
|
||||
inactiveElements.forEach((element) => {
|
||||
expect(element.querySelector('.ticket--disabled')).not.toBeNull();
|
||||
expect(element.querySelector<HTMLButtonElement>('app-button button')?.disabled).toBe(true);
|
||||
expect(element.querySelector('input[type="checkbox"]')).toBeNull();
|
||||
expect(element.querySelector('[aria-label="Descargar ticket"]')).toBeNull();
|
||||
expect(element.querySelector('[aria-label="Compartir ticket"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,17 @@ export class TicketsPage implements OnInit {
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
protected readonly tickets = signal<TicketResponse[]>([]);
|
||||
protected readonly activeTickets = computed(() =>
|
||||
this.tickets().filter((ticket) => !this.isInactive(ticket)),
|
||||
);
|
||||
protected readonly inactiveTickets = computed(() =>
|
||||
this.tickets().filter((ticket) => this.isInactive(ticket)),
|
||||
);
|
||||
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,
|
||||
() =>
|
||||
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -34,7 +41,9 @@ export class TicketsPage implements OnInit {
|
||||
}
|
||||
|
||||
protected toggleAll(selected: boolean): void {
|
||||
this.selectedIds.set(selected ? new Set(this.tickets().map((ticket) => ticket.id)) : new Set());
|
||||
this.selectedIds.set(
|
||||
selected ? new Set(this.activeTickets().map((ticket) => ticket.id)) : new Set(),
|
||||
);
|
||||
}
|
||||
|
||||
protected toggleTicket(ticketId: number, selected: boolean): void {
|
||||
@@ -49,6 +58,10 @@ export class TicketsPage implements OnInit {
|
||||
return this.selectedIds().has(ticketId);
|
||||
}
|
||||
|
||||
protected isInactive(ticket: TicketResponse): boolean {
|
||||
return ticket.is_expired || ticket.is_used;
|
||||
}
|
||||
|
||||
protected formatDate(ticket: TicketResponse): string | null {
|
||||
const value = ticket.starts_at ?? ticket.expires_at;
|
||||
if (!value) return null;
|
||||
|
||||
Reference in New Issue
Block a user