feat(ticket): add reusable ticket component with actions and styles
This commit is contained in:
@@ -3,9 +3,7 @@
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Componentes reutilizables</span>
|
||||
<h1>Button + Input</h1>
|
||||
<p class="lead mb-4">
|
||||
Pagina dedicada a probar variantes de los componentes compartidos.
|
||||
</p>
|
||||
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
|
||||
|
||||
<div class="button-showcase">
|
||||
<div class="button-group">
|
||||
@@ -26,9 +24,7 @@
|
||||
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
|
||||
|
||||
<app-button variant="danger" [disabled]="true">Danger</app-button>
|
||||
<app-button variant="danger-secondary" [disabled]="true">
|
||||
Danger secondary
|
||||
</app-button>
|
||||
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
|
||||
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,13 +56,8 @@
|
||||
<div class="button-group">
|
||||
<h2>Toasts Persistentes (No se van)</h2>
|
||||
<div class="button-grid">
|
||||
<app-button (click)="triggerPersistentToast('info')">
|
||||
Info Persistente
|
||||
</app-button>
|
||||
<app-button
|
||||
variant="secondary"
|
||||
(click)="triggerPersistentToast('success')"
|
||||
>
|
||||
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
|
||||
<app-button variant="secondary" (click)="triggerPersistentToast('success')">
|
||||
Success Persistente
|
||||
</app-button>
|
||||
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
|
||||
@@ -91,9 +82,7 @@
|
||||
<app-button variant="danger-secondary" (click)="openWideModal()">
|
||||
Abrir modal ancho
|
||||
</app-button>
|
||||
<app-button (click)="openSimpleModal()">
|
||||
Abrir simple modal
|
||||
</app-button>
|
||||
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
|
||||
</div>
|
||||
<div class="modal-showcase__result" data-testid="modal-last-result">
|
||||
{{ lastModalResult }}
|
||||
@@ -106,9 +95,9 @@
|
||||
<div class="icon-button-showcase">
|
||||
<h2>Icon Buttons</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Botones de icono pequenos con color base <code>#666666</code>,
|
||||
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
|
||||
de marca (a excepcion de trash que usa danger).
|
||||
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
|
||||
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
|
||||
usa danger).
|
||||
</p>
|
||||
|
||||
<div class="icon-button-showcase__grid">
|
||||
@@ -155,9 +144,7 @@
|
||||
|
||||
<div class="icon-button-showcase mt-4">
|
||||
<h2>Cart Icons</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Icono de carrito con badge opcional indicando cantidad.
|
||||
</p>
|
||||
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
|
||||
|
||||
<div class="icon-button-showcase__grid">
|
||||
<div class="icon-button-showcase__row">
|
||||
@@ -200,12 +187,7 @@
|
||||
|
||||
<div class="input-field">
|
||||
<label for="number-input">Numero</label>
|
||||
<app-input
|
||||
id="number-input"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
[(value)]="numberValue"
|
||||
/>
|
||||
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
@@ -220,12 +202,7 @@
|
||||
|
||||
<div class="input-field">
|
||||
<label for="user-input">Usuario</label>
|
||||
<app-input
|
||||
id="user-input"
|
||||
type="user"
|
||||
placeholder="Nombre de usuario"
|
||||
[(value)]="user"
|
||||
/>
|
||||
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
@@ -250,22 +227,12 @@
|
||||
|
||||
<div class="input-field">
|
||||
<label for="amount-input">Moneda</label>
|
||||
<app-input
|
||||
id="amount-input"
|
||||
type="currency"
|
||||
placeholder="0"
|
||||
[(value)]="amount"
|
||||
/>
|
||||
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
<label for="percent-input">Porcentaje</label>
|
||||
<app-input
|
||||
id="percent-input"
|
||||
type="percent"
|
||||
placeholder="0"
|
||||
[(value)]="percent"
|
||||
/>
|
||||
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
@@ -313,9 +280,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<app-button variant="secondary" (click)="clearFile()">
|
||||
Limpiar archivo
|
||||
</app-button>
|
||||
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,8 +289,7 @@
|
||||
<div class="paginator-showcase">
|
||||
<h2>Paginator</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Componente shared para navegar por paginas con primera, anterior,
|
||||
siguiente y ultima.
|
||||
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
|
||||
</p>
|
||||
<app-paginator
|
||||
[page]="currentPage"
|
||||
@@ -339,11 +303,11 @@
|
||||
<div class="stepper-showcase">
|
||||
<h2>Form Stepper</h2>
|
||||
<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>
|
||||
|
||||
<app-stepper #demoStepper>
|
||||
|
||||
<!-- Paso 1: Siempre valido -->
|
||||
<app-step label="Informacion Personal" [isValid]="true">
|
||||
<div class="row g-3">
|
||||
@@ -353,7 +317,12 @@
|
||||
</div>
|
||||
<div class="col-md-6 input-field">
|
||||
<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 class="d-flex justify-content-end mt-4">
|
||||
@@ -368,7 +337,12 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6 input-field">
|
||||
<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 class="col-md-6 input-field">
|
||||
<label for="stepper-amount">Presupuesto mensual</label>
|
||||
@@ -377,9 +351,7 @@
|
||||
</div>
|
||||
|
||||
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
|
||||
<span class="text-muted small">
|
||||
Simular validez del paso:
|
||||
</span>
|
||||
<span class="text-muted small"> Simular validez del paso: </span>
|
||||
<app-button
|
||||
[variant]="testStep2Valid ? 'secondary' : 'primary'"
|
||||
(click)="testStep2Valid = !testStep2Valid"
|
||||
@@ -418,7 +390,6 @@
|
||||
</app-button>
|
||||
</div>
|
||||
</app-step>
|
||||
|
||||
</app-stepper>
|
||||
</div>
|
||||
|
||||
@@ -454,7 +425,6 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -604,14 +574,32 @@
|
||||
</div>
|
||||
</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">
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Reusable Component</span>
|
||||
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
|
||||
presentan dentro de una grilla de Bootstrap.
|
||||
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
|
||||
de una grilla de Bootstrap.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
@@ -636,9 +624,7 @@
|
||||
<span class="eyebrow">Reusable Component</span>
|
||||
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Diseño horizontal que ocupa el 100% del ancho disponible.
|
||||
</p>
|
||||
<p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4 d-flex align-items-stretch">
|
||||
@@ -678,10 +664,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<app-store-section
|
||||
title="PRODUCTOS"
|
||||
class="component-demo store-section-demo mt-5"
|
||||
>
|
||||
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
|
||||
<div class="row">
|
||||
@for (product of testProducts; track product.title) {
|
||||
<div
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import {
|
||||
CartComponent,
|
||||
CartItemMock
|
||||
} from '../../../../shared/components/cart/cart.component';
|
||||
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
|
||||
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.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 {
|
||||
ProductVerticalWithCartCardComponent
|
||||
} from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
|
||||
import { 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 { ModalService } from '../../../../core/services/modal.service';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
||||
import { TicketComponent } from '../../../../shared/components/ticket/ticket.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reutilizables-test-page',
|
||||
@@ -34,10 +30,11 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
|
||||
IconButtonComponent,
|
||||
CartIconComponent,
|
||||
StepperComponent,
|
||||
StepComponent
|
||||
StepComponent,
|
||||
TicketComponent,
|
||||
],
|
||||
templateUrl: './reutilizables-test-page.component.html',
|
||||
styleUrl: './reutilizables-test-page.component.scss'
|
||||
styleUrl: './reutilizables-test-page.component.scss',
|
||||
})
|
||||
export class ReutilizablesTestPageComponent {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
@@ -74,38 +71,39 @@ export class ReutilizablesTestPageComponent {
|
||||
originalPrice: 95000,
|
||||
discount: 20,
|
||||
transferPrice: 74800,
|
||||
imageUrl: '/images/pantalon-scuba.png'
|
||||
imageUrl: '/images/pantalon-scuba.png',
|
||||
},
|
||||
{
|
||||
title: 'Zapatillas de running azul',
|
||||
originalPrice: 120000,
|
||||
discount: 15,
|
||||
transferPrice: 98000,
|
||||
imageUrl: '/images/zapatillas-running.png'
|
||||
imageUrl: '/images/zapatillas-running.png',
|
||||
},
|
||||
{
|
||||
title: 'Remera basica blanca (Sin Descuento)',
|
||||
originalPrice: 32000,
|
||||
discount: null,
|
||||
transferPrice: 30000,
|
||||
imageUrl: null
|
||||
}
|
||||
imageUrl: null,
|
||||
},
|
||||
];
|
||||
|
||||
protected readonly testRowProduct = {
|
||||
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,
|
||||
variants: [
|
||||
{ label: '10 de Octubre', value: '10-oct' },
|
||||
{ label: '11 de Octubre', value: '11-oct' }
|
||||
]
|
||||
{ label: '11 de Octubre', value: '11-oct' },
|
||||
],
|
||||
};
|
||||
|
||||
protected readonly testVerticalWithCartProduct = {
|
||||
title: 'PANCHO',
|
||||
description: 'Pancho con aderezo a elección.',
|
||||
price: 11500
|
||||
price: 11500,
|
||||
};
|
||||
|
||||
protected readonly cartMockItems: CartItemMock[] = [
|
||||
@@ -118,8 +116,8 @@ export class ReutilizablesTestPageComponent {
|
||||
quantity: 1,
|
||||
attributes: [
|
||||
{ label: 'Color', value: 'Beige' },
|
||||
{ label: 'Talle', value: 'S' }
|
||||
]
|
||||
{ label: 'Talle', value: 'S' },
|
||||
],
|
||||
},
|
||||
{
|
||||
imageUrl: null,
|
||||
@@ -130,8 +128,8 @@ export class ReutilizablesTestPageComponent {
|
||||
quantity: 1,
|
||||
attributes: [
|
||||
{ label: 'Color', value: 'Beige' },
|
||||
{ label: 'Talle', value: 'S' }
|
||||
]
|
||||
{ label: 'Talle', value: 'S' },
|
||||
],
|
||||
},
|
||||
{
|
||||
imageUrl: null,
|
||||
@@ -142,8 +140,8 @@ export class ReutilizablesTestPageComponent {
|
||||
quantity: 1,
|
||||
attributes: [
|
||||
{ label: 'Color', value: 'Negro' },
|
||||
{ label: 'Talle', value: 'XL' }
|
||||
]
|
||||
{ label: 'Talle', value: 'XL' },
|
||||
],
|
||||
},
|
||||
{
|
||||
imageUrl: null,
|
||||
@@ -154,9 +152,9 @@ export class ReutilizablesTestPageComponent {
|
||||
quantity: 2,
|
||||
attributes: [
|
||||
{ label: 'Color', value: 'Gris' },
|
||||
{ label: 'Talle', value: 'M' }
|
||||
]
|
||||
}
|
||||
{ label: 'Talle', value: 'M' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
protected onProductBuy(productTitle: string): void {
|
||||
@@ -184,45 +182,32 @@ export class ReutilizablesTestPageComponent {
|
||||
|
||||
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
|
||||
if (type === 'success') {
|
||||
this.toastService.success(
|
||||
'Operacion exitosa. El producto se ha guardado correctamente.'
|
||||
);
|
||||
this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
|
||||
} else if (type === 'danger') {
|
||||
this.toastService.danger(
|
||||
'Alerta de error. Hubo un problema al procesar la solicitud.'
|
||||
);
|
||||
this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
|
||||
} else if (type === 'info') {
|
||||
this.toastService.info(
|
||||
'Informacion del sistema: Hay una nueva actualizacion disponible.'
|
||||
);
|
||||
this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
|
||||
}
|
||||
}
|
||||
|
||||
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
|
||||
if (type === 'success') {
|
||||
this.toastService.success(
|
||||
'Exito persistente. Esta alerta no se cerrara sola.',
|
||||
0
|
||||
);
|
||||
this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
|
||||
} else if (type === 'danger') {
|
||||
this.toastService.danger(
|
||||
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
|
||||
0
|
||||
0,
|
||||
);
|
||||
} else if (type === 'info') {
|
||||
this.toastService.info(
|
||||
'Informacion persistente: Mantendremos este aviso en pantalla.',
|
||||
0
|
||||
);
|
||||
this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
|
||||
}
|
||||
}
|
||||
|
||||
protected openBasicModal(): void {
|
||||
this.openConfirmModal({
|
||||
title: 'Confirmar accion',
|
||||
content:
|
||||
'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||
confirmLabel: 'Confirmar'
|
||||
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||
confirmLabel: 'Confirmar',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,18 +216,17 @@ export class ReutilizablesTestPageComponent {
|
||||
title: 'Eliminar producto',
|
||||
content:
|
||||
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
|
||||
confirmLabel: 'Eliminar'
|
||||
confirmLabel: 'Eliminar',
|
||||
});
|
||||
}
|
||||
|
||||
protected openLockedModal(): void {
|
||||
this.openConfirmModal({
|
||||
title: 'Modal bloqueado',
|
||||
content:
|
||||
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
|
||||
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
|
||||
confirmLabel: 'Entendido',
|
||||
closeOnBackdrop: false,
|
||||
closeOnEscape: false
|
||||
closeOnEscape: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -251,18 +235,20 @@ export class ReutilizablesTestPageComponent {
|
||||
title: 'Modal ancho',
|
||||
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
|
||||
confirmLabel: 'Seguir',
|
||||
size: 'xl'
|
||||
size: 'xl',
|
||||
});
|
||||
}
|
||||
|
||||
protected openSimpleModal(): void {
|
||||
this.modalService.openSimple({
|
||||
title: 'Mensaje del sistema',
|
||||
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
|
||||
buttonLabel: 'Entendido'
|
||||
}).subscribe(() => {
|
||||
this.lastModalResult = 'Simple modal cerrado';
|
||||
});
|
||||
this.modalService
|
||||
.openSimple({
|
||||
title: 'Mensaje del sistema',
|
||||
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
|
||||
buttonLabel: 'Entendido',
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.lastModalResult = 'Simple modal cerrado';
|
||||
});
|
||||
}
|
||||
|
||||
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
|
||||
@@ -271,12 +257,9 @@ export class ReutilizablesTestPageComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private openConfirmDelete(
|
||||
config: Parameters<ModalService['openConfirmDelete']>[0]
|
||||
): void {
|
||||
private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
|
||||
this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
|
||||
this.lastModalResult = `Resultado: ${confirmed}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
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;
|
||||
|
||||
i {
|
||||
@@ -40,7 +42,7 @@
|
||||
|
||||
// Disabled state
|
||||
&:disabled {
|
||||
color: #A0A0A0;
|
||||
color: #a0a0a0;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -74,3 +76,14 @@
|
||||
font-size: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn--download,
|
||||
.icon-btn--share {
|
||||
color: #a0a0a0;
|
||||
|
||||
i {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@ export type IconButtonVariant =
|
||||
| 'angle-left'
|
||||
| 'angle-up'
|
||||
| 'angle-down'
|
||||
| 'copy';
|
||||
| 'copy'
|
||||
| 'download'
|
||||
| 'share';
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon-button',
|
||||
imports: [NgClass],
|
||||
templateUrl: './icon-button.component.html',
|
||||
styleUrl: './icon-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class IconButtonComponent {
|
||||
readonly variant = input.required<IconButtonVariant>();
|
||||
@@ -33,7 +35,9 @@ export class IconButtonComponent {
|
||||
'angle-left': 'fa-solid fa-angle-left',
|
||||
'angle-up': 'fa-solid fa-angle-up',
|
||||
'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()];
|
||||
});
|
||||
|
||||
30
src/app/shared/components/ticket/ticket.component.html
Normal file
30
src/app/shared/components/ticket/ticket.component.html
Normal 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>
|
||||
100
src/app/shared/components/ticket/ticket.component.scss
Normal file
100
src/app/shared/components/ticket/ticket.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
50
src/app/shared/components/ticket/ticket.component.spec.ts
Normal file
50
src/app/shared/components/ticket/ticket.component.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
src/app/shared/components/ticket/ticket.component.ts
Normal file
21
src/app/shared/components/ticket/ticket.component.ts
Normal 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>();
|
||||
}
|
||||
Reference in New Issue
Block a user