feat(ticket): add reusable ticket component with actions and styles

This commit is contained in:
2026-07-21 13:37:31 -03:00
parent 8f57af3cf5
commit 54826a7f8a
8 changed files with 321 additions and 137 deletions

View File

@@ -3,9 +3,7 @@
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Componentes reutilizables</span> <span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1> <h1>Button + Input</h1>
<p class="lead mb-4"> <p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<div class="button-showcase"> <div class="button-showcase">
<div class="button-group"> <div class="button-group">
@@ -26,9 +24,7 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button> <app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button> <app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true"> <app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
Danger secondary
</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button> <app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div> </div>
</div> </div>
@@ -60,13 +56,8 @@
<div class="button-group"> <div class="button-group">
<h2>Toasts Persistentes (No se van)</h2> <h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid"> <div class="button-grid">
<app-button (click)="triggerPersistentToast('info')"> <app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
Info Persistente <app-button variant="secondary" (click)="triggerPersistentToast('success')">
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
Success Persistente Success Persistente
</app-button> </app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')"> <app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -91,9 +82,7 @@
<app-button variant="danger-secondary" (click)="openWideModal()"> <app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho Abrir modal ancho
</app-button> </app-button>
<app-button (click)="openSimpleModal()"> <app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
Abrir simple modal
</app-button>
</div> </div>
<div class="modal-showcase__result" data-testid="modal-last-result"> <div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }} {{ lastModalResult }}
@@ -106,9 +95,9 @@
<div class="icon-button-showcase"> <div class="icon-button-showcase">
<h2>Icon Buttons</h2> <h2>Icon Buttons</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>, Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color <code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
de marca (a excepcion de trash que usa danger). usa danger).
</p> </p>
<div class="icon-button-showcase__grid"> <div class="icon-button-showcase__grid">
@@ -155,9 +144,7 @@
<div class="icon-button-showcase mt-4"> <div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2> <h2>Cart Icons</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
Icono de carrito con badge opcional indicando cantidad.
</p>
<div class="icon-button-showcase__grid"> <div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row"> <div class="icon-button-showcase__row">
@@ -200,12 +187,7 @@
<div class="input-field"> <div class="input-field">
<label for="number-input">Numero</label> <label for="number-input">Numero</label>
<app-input <app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -220,12 +202,7 @@
<div class="input-field"> <div class="input-field">
<label for="user-input">Usuario</label> <label for="user-input">Usuario</label>
<app-input <app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -250,22 +227,12 @@
<div class="input-field"> <div class="input-field">
<label for="amount-input">Moneda</label> <label for="amount-input">Moneda</label>
<app-input <app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
<label for="percent-input">Porcentaje</label> <label for="percent-input">Porcentaje</label>
<app-input <app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -313,9 +280,7 @@
/> />
</div> </div>
<app-button variant="secondary" (click)="clearFile()"> <app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
Limpiar archivo
</app-button>
</div> </div>
</div> </div>
@@ -324,8 +289,7 @@
<div class="paginator-showcase"> <div class="paginator-showcase">
<h2>Paginator</h2> <h2>Paginator</h2>
<p class="text-muted mb-0"> <p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
siguiente y ultima.
</p> </p>
<app-paginator <app-paginator
[page]="currentPage" [page]="currentPage"
@@ -339,11 +303,11 @@
<div class="stepper-showcase"> <div class="stepper-showcase">
<h2>Form Stepper</h2> <h2>Form Stepper</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido. Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
puede avanzar si el paso no es valido.
</p> </p>
<app-stepper #demoStepper> <app-stepper #demoStepper>
<!-- Paso 1: Siempre valido --> <!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true"> <app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3"> <div class="row g-3">
@@ -353,7 +317,12 @@
</div> </div>
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-email">Email</label> <label for="stepper-email">Email</label>
<app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" /> <app-input
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
</div> </div>
</div> </div>
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
@@ -368,7 +337,12 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label> <label for="stepper-password">Contraseña</label>
<app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" /> <app-input
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
</div> </div>
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label> <label for="stepper-amount">Presupuesto mensual</label>
@@ -377,9 +351,7 @@
</div> </div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3"> <div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small"> <span class="text-muted small"> Simular validez del paso: </span>
Simular validez del paso:
</span>
<app-button <app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'" [variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid" (click)="testStep2Valid = !testStep2Valid"
@@ -418,7 +390,6 @@
</app-button> </app-button>
</div> </div>
</app-step> </app-step>
</app-stepper> </app-stepper>
</div> </div>
@@ -454,7 +425,6 @@
</div> </div>
} }
</div> </div>
</div> </div>
</section> </section>
@@ -604,14 +574,32 @@
</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>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2> <h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
presentan dentro de una grilla de Bootstrap. de una grilla de Bootstrap.
</p> </p>
<div class="row"> <div class="row">
@@ -636,9 +624,7 @@
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2> <h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
Diseño horizontal que ocupa el 100% del ancho disponible.
</p>
<div class="row"> <div class="row">
<div class="col-12 mb-4 d-flex align-items-stretch"> <div class="col-12 mb-4 d-flex align-items-stretch">
@@ -678,10 +664,7 @@
</div> </div>
</section> </section>
<app-store-section <app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<div class="row"> <div class="row">
@for (product of testProducts; track product.title) { @for (product of testProducts; track product.title) {
<div <div

View File

@@ -1,24 +1,20 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component'; import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component'; import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component'; import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
ProductVerticalWithCartCardComponent
} from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service'; import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service'; 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',
@@ -34,10 +30,11 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
IconButtonComponent, IconButtonComponent,
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',
}) })
export class ReutilizablesTestPageComponent { export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -74,38 +71,39 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000, originalPrice: 95000,
discount: 20, discount: 20,
transferPrice: 74800, transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png' imageUrl: '/images/pantalon-scuba.png',
}, },
{ {
title: 'Zapatillas de running azul', title: 'Zapatillas de running azul',
originalPrice: 120000, originalPrice: 120000,
discount: 15, discount: 15,
transferPrice: 98000, transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png' imageUrl: '/images/zapatillas-running.png',
}, },
{ {
title: 'Remera basica blanca (Sin Descuento)', title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000, originalPrice: 32000,
discount: null, discount: null,
transferPrice: 30000, transferPrice: 30000,
imageUrl: null imageUrl: null,
} },
]; ];
protected readonly testRowProduct = { protected readonly testRowProduct = {
title: 'ENTRADA GENERAL', title: 'ENTRADA GENERAL',
description: 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', description:
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000, price: 10000,
variants: [ variants: [
{ label: '10 de Octubre', value: '10-oct' }, { label: '10 de Octubre', value: '10-oct' },
{ label: '11 de Octubre', value: '11-oct' } { label: '11 de Octubre', value: '11-oct' },
] ],
}; };
protected readonly testVerticalWithCartProduct = { protected readonly testVerticalWithCartProduct = {
title: 'PANCHO', title: 'PANCHO',
description: 'Pancho con aderezo a elección.', description: 'Pancho con aderezo a elección.',
price: 11500 price: 11500,
}; };
protected readonly cartMockItems: CartItemMock[] = [ protected readonly cartMockItems: CartItemMock[] = [
@@ -118,8 +116,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' } { label: 'Talle', value: 'S' },
] ],
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -130,8 +128,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' } { label: 'Talle', value: 'S' },
] ],
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -142,8 +140,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Negro' }, { label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' } { label: 'Talle', value: 'XL' },
] ],
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -154,9 +152,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2, quantity: 2,
attributes: [ attributes: [
{ label: 'Color', value: 'Gris' }, { label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' } { label: 'Talle', value: 'M' },
] ],
} },
]; ];
protected onProductBuy(productTitle: string): void { protected onProductBuy(productTitle: string): void {
@@ -184,45 +182,32 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void { protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { if (type === 'success') {
this.toastService.success( this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info( this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
} }
} }
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void { protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { if (type === 'success') {
this.toastService.success( this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.', 'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0 0,
); );
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info( this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
} }
} }
protected openBasicModal(): void { protected openBasicModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Confirmar accion', title: 'Confirmar accion',
content: content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
'Caso base para verificar apertura, cierre y devolucion de resultado.', confirmLabel: 'Confirmar',
confirmLabel: 'Confirmar'
}); });
} }
@@ -231,18 +216,17 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto', title: 'Eliminar producto',
content: content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar' confirmLabel: 'Eliminar',
}); });
} }
protected openLockedModal(): void { protected openLockedModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Modal bloqueado', title: 'Modal bloqueado',
content: content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido', confirmLabel: 'Entendido',
closeOnBackdrop: false, closeOnBackdrop: false,
closeOnEscape: false closeOnEscape: false,
}); });
} }
@@ -251,18 +235,20 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho', title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.', content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir', confirmLabel: 'Seguir',
size: 'xl' size: 'xl',
}); });
} }
protected openSimpleModal(): void { protected openSimpleModal(): void {
this.modalService.openSimple({ this.modalService
title: 'Mensaje del sistema', .openSimple({
content: 'Este es un mensaje simple del sistema que no requiere confirmación.', title: 'Mensaje del sistema',
buttonLabel: 'Entendido' content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
}).subscribe(() => { buttonLabel: 'Entendido',
this.lastModalResult = 'Simple modal cerrado'; })
}); .subscribe(() => {
this.lastModalResult = 'Simple modal cerrado';
});
} }
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void { private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
@@ -271,12 +257,9 @@ export class ReutilizablesTestPageComponent {
}); });
} }
private openConfirmDelete( private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
config: Parameters<ModalService['openConfirmDelete']>[0]
): void {
this.modalService.openConfirmDelete(config).subscribe((confirmed) => { this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`; this.lastModalResult = `Resultado: ${confirmed}`;
}); });
} }
} }

View File

@@ -14,7 +14,9 @@
color: #666666; color: #666666;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out; transition:
color 0.15s ease-in-out,
transform 0.1s ease-in-out;
border-radius: 4px; border-radius: 4px;
i { i {
@@ -40,7 +42,7 @@
// Disabled state // Disabled state
&:disabled { &:disabled {
color: #A0A0A0; color: #a0a0a0;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;
} }
@@ -74,3 +76,14 @@
font-size: 23px; font-size: 23px;
} }
} }
.icon-btn--download,
.icon-btn--share {
color: #a0a0a0;
i {
width: 15px;
height: 15px;
font-size: 15px;
}
}

View File

@@ -8,14 +8,16 @@ export type IconButtonVariant =
| 'angle-left' | 'angle-left'
| 'angle-up' | 'angle-up'
| 'angle-down' | 'angle-down'
| 'copy'; | 'copy'
| 'download'
| 'share';
@Component({ @Component({
selector: 'app-icon-button', selector: 'app-icon-button',
imports: [NgClass], imports: [NgClass],
templateUrl: './icon-button.component.html', templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss', styleUrl: './icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class IconButtonComponent { export class IconButtonComponent {
readonly variant = input.required<IconButtonVariant>(); readonly variant = input.required<IconButtonVariant>();
@@ -33,7 +35,9 @@ export class IconButtonComponent {
'angle-left': 'fa-solid fa-angle-left', 'angle-left': 'fa-solid fa-angle-left',
'angle-up': 'fa-solid fa-angle-up', 'angle-up': 'fa-solid fa-angle-up',
'angle-down': 'fa-solid fa-angle-down', 'angle-down': 'fa-solid fa-angle-down',
copy: 'fa-solid fa-copy' copy: 'fa-solid fa-copy',
download: 'fa-solid fa-download',
share: 'fa-solid fa-share-nodes',
}; };
return classes[this.variant()]; return classes[this.variant()];
}); });

View File

@@ -0,0 +1,30 @@
<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>
<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>
}
<div class="ticket__actions">
<app-button variant="primary" buttonClass="ticket__qr-button" (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()" />
</div>
</article>

View File

@@ -0,0 +1,100 @@
:host {
display: block;
width: 100%;
}
.ticket {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
min-height: 56px;
gap: 16px;
padding: 16px 0;
background: transparent;
color: #666666;
}
.ticket__selection {
display: flex;
margin: 0;
cursor: pointer;
}
.ticket__selection input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
cursor: pointer;
}
.ticket__details {
min-width: 0;
}
.ticket__title,
.ticket__id {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket__title {
font-size: 13px;
font-weight: 700;
line-height: 1.25;
color: #666666;
}
.ticket__id {
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: #666666;
}
.ticket__date {
font-size: 10px;
font-weight: 400;
line-height: 1.4;
color: #666666;
white-space: nowrap;
}
.ticket__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
:host ::ng-deep .ticket__qr-button {
min-width: auto;
min-height: 36px;
padding-inline: 14px;
}
.ticket__selection input:focus-visible {
outline: 2px solid var(--tenant-primary, #009933);
outline-offset: 2px;
}
@media (max-width: 575.98px) {
.ticket {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
padding: 20px 0;
}
.ticket__date {
grid-column: 2;
grid-row: 2;
}
.ticket__actions {
grid-column: 3;
grid-row: 1 / span 2;
}
}

View File

@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { TicketComponent } from './ticket.component';
describe('TicketComponent', () => {
it('renders the ticket data and emits every action', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de Octubre');
const viewQr = vi.fn();
const download = vi.fn();
const share = vi.fn();
fixture.componentInstance.viewQr.subscribe(viewQr);
fixture.componentInstance.download.subscribe(download);
fixture.componentInstance.share.subscribe(share);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('ENTRADA GENERAL');
expect(element.textContent).toContain('ID: 0000000000');
expect(element.textContent).toContain('09 de Octubre');
element.querySelector<HTMLButtonElement>('app-button button')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
expect(viewQr).toHaveBeenCalledOnce();
expect(download).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);
});
});

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { IconButtonComponent } from '../icon-button/icon-button.component';
@Component({
selector: 'app-ticket',
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './ticket.component.html',
styleUrl: './ticket.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TicketComponent {
readonly title = input('ENTRADA GENERAL');
readonly ticketId = input.required<string>();
readonly date = input<string | null>(null);
readonly selected = model(false);
readonly viewQr = output<void>();
readonly download = output<void>();
readonly share = output<void>();
}