@cbm-common/tip-modal
v0.0.1
Published
Componente modal de Angular para el cálculo y gestión de propinas en sistemas de punto de venta y restaurantes. Implementa un modal interactivo con validaciones avanzadas, cálculos automáticos y integración completa con formularios reactivos.
Readme
Tip Modal
Componente modal de Angular para el cálculo y gestión de propinas en sistemas de punto de venta y restaurantes. Implementa un modal interactivo con validaciones avanzadas, cálculos automáticos y integración completa con formularios reactivos.
📦 Instalación
npm install @cbm-common/tip-modal⚙️ Configuración
Importación del Componente
El componente es standalone, por lo que se puede importar directamente:
import { CbmTipModalComponent } from '@cbm-common/tip-modal';
@Component({
selector: 'app-tip-calculator',
standalone: true,
imports: [CbmTipModalComponent],
template: `
<cbm-tip-modal
[isOpen]="showTipModal"
[tip]="currentTip"
[tipControl]="tipFormControl"
[subtotal]="orderSubtotal"
(close)="showTipModal = false"
(open)="onModalOpen()"
/>
`
})
export class TipCalculatorComponent {
showTipModal = false;
currentTip = '0';
orderSubtotal = 25.50;
tipFormControl = new FormControl<string | null>('0', [
Validators.required,
Validators.min(0)
]);
onModalOpen() {
console.log('Modal de propina abierto');
}
}🎯 Propiedades de Entrada (Inputs)
isOpen: boolean (Requerido)
Controla la visibilidad del modal.
// Abrir modal
this.showTipModal = true;
// Cerrar modal
this.showTipModal = false;tip: string | null (Requerido)
Valor actual de la propina como string.
currentTip = '2.50'; // $2.50 de propinatipControl: FormControl<string | null> (Requerido)
Control de formulario reactivo para la propina.
tipFormControl = new FormControl<string | null>('0', [
Validators.required,
Validators.min(0)
]);subtotal: number (Requerido)
Subtotal de la orden para calcular porcentajes.
orderSubtotal = 25.50; // Subtotal de $25.50📤 Eventos de Salida (Outputs)
open: void
Se emite cuando el modal se abre.
(open)="onModalOpen()"close: void
Se emite cuando el modal se cierra.
(close)="onModalClose()"🏗️ Arquitectura del Componente
Patrón de Diseño
El componente sigue el patrón Reactive Forms integrado con Signals de Angular:
CbmTipModalComponent
├── FormGroup con validaciones personalizadas
├── Cálculos automáticos entre porcentaje/valor
├── Efectos para sincronización reactiva
├── Animaciones personalizadas
└── Integración con directivas CBMFormulario Interno
tipForm = new FormGroup({
percentage: new FormControl<string | null>(null, [
Validators.required,
Validators.min(0),
Validators.max(10)
]),
value: new FormControl<string | null>(null, [
Validators.required,
Validators.min(0),
this.valueValidator()
])
});Validaciones Personalizadas
- Porcentaje: 0-10% del subtotal
- Valor: Máximo 10% del subtotal
- Sincronización: Cálculo automático entre campos
🧮 Funcionalidades de Cálculo
Cálculo Automático
El componente calcula automáticamente entre porcentaje y valor:
// Si cambias el porcentaje:
percentage = 5; // 5%
// Se calcula automáticamente:
value = (5/100) * subtotal; // 5% de $25.50 = $1.275
// Si cambias el valor:
value = 2.50; // $2.50
// Se calcula automáticamente:
percentage = (2.50 / subtotal) * 100; // ($2.50 / $25.50) * 100 = 9.8%Validaciones de Negocio
- Límite máximo: La propina no puede exceder el 10% del subtotal
- Valores positivos: No se permiten valores negativos
- Precisión decimal: Hasta 2 decimales
🎨 Características Visuales
Diseño Responsive
- Mobile-first: Optimizado para dispositivos móviles
- Grid adaptable: 1 columna en móvil, 2 en desktop
- Inputs numéricos: Con formato monospace y alineación derecha
Animaciones
- Entrada/Salida: Transiciones suaves con opacidad
- Validaciones: Animaciones en mensajes de error
- Botones: Efectos hover y estados disabled
Tema y Estilos
- Tailwind CSS: Framework de utilidades integrado
- Shadow DOM: Encapsulación completa de estilos
- Colores temáticos: Paleta consistente con el sistema CBM
🚀 Ejemplos de Uso
Ejemplo Básico
@Component({
selector: 'app-order-tip',
standalone: true,
imports: [CbmTipModalComponent],
template: `
<div class="order-summary">
<div class="subtotal">Subtotal: {{ subtotal | currency }}</div>
<div class="tip">Propina: {{ tip || 0 | currency }}</div>
<div class="total">Total: {{ (subtotal + (tip || 0)) | currency }}</div>
<button (click)="openTipModal()" class="btn-primary">
Agregar Propina
</button>
<cbm-tip-modal
[isOpen]="showTipModal"
[tip]="tip?.toString()"
[tipControl]="tipControl"
[subtotal]="subtotal"
(close)="showTipModal = false"
/>
</div>
`
})
export class OrderTipComponent {
subtotal = 25.50;
tip: number | null = null;
showTipModal = false;
tipControl = new FormControl<string | null>('0', [
Validators.required,
Validators.min(0)
]);
constructor() {
// Escuchar cambios en el control de propina
this.tipControl.valueChanges.subscribe(value => {
this.tip = value ? parseFloat(value) : null;
});
}
openTipModal() {
this.showTipModal = true;
}
}Ejemplo con Formulario Reactivo Completo
@Component({
selector: 'app-restaurant-order',
standalone: true,
imports: [CbmTipModalComponent, ReactiveFormsModule],
template: `
<form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
<div class="order-items">
<!-- Items del pedido -->
</div>
<div class="order-summary">
<div>Subtotal: {{ orderForm.get('subtotal')?.value | currency }}</div>
<div>Propina: {{ orderForm.get('tip')?.value || 0 | currency }}</div>
<div>Total: {{ getTotal() | currency }}</div>
</div>
<div class="actions">
<button type="button" (click)="openTipModal()" class="btn-secondary">
Modificar Propina
</button>
<button type="submit" class="btn-primary">
Procesar Pedido
</button>
</div>
<cbm-tip-modal
[isOpen]="showTipModal"
[tip]="orderForm.get('tip')?.value?.toString()"
[tipControl]="orderForm.get('tip')"
[subtotal]="orderForm.get('subtotal')?.value"
(close)="showTipModal = false"
/>
</form>
`
})
export class RestaurantOrderComponent {
showTipModal = false;
orderForm = this.fb.group({
items: this.fb.array([]),
subtotal: [25.50],
tip: ['0', [Validators.required, Validators.min(0)]],
total: [25.50]
});
constructor(private fb: FormBuilder) {
// Calcular total cuando cambie subtotal o propina
this.orderForm.valueChanges.subscribe(() => {
this.calculateTotal();
});
}
getTotal(): number {
const subtotal = this.orderForm.get('subtotal')?.value || 0;
const tip = this.orderForm.get('tip')?.value || 0;
return subtotal + parseFloat(tip);
}
calculateTotal() {
this.orderForm.patchValue({
total: this.getTotal()
}, { emitEvent: false });
}
openTipModal() {
this.showTipModal = true;
}
onSubmit() {
if (this.orderForm.valid) {
console.log('Procesar pedido:', this.orderForm.value);
}
}
}Ejemplo con Integración de API
@Component({
selector: 'app-pos-system',
standalone: true,
imports: [CbmTipModalComponent],
template: `
<div class="pos-interface">
<div class="order-display">
<h3>Pedido Actual</h3>
<div class="order-details">
<div>Subtotal: {{ currentOrder.subtotal | currency }}</div>
<div>Propina: {{ currentOrder.tip | currency }}</div>
<div class="total-line">
<strong>Total: {{ getTotal() | currency }}</strong>
</div>
</div>
</div>
<div class="action-buttons">
<button (click)="addTip()" class="btn-tip">
💰 Agregar Propina
</button>
<button (click)="processPayment()" class="btn-pay">
💳 Procesar Pago
</button>
</div>
<cbm-tip-modal
[isOpen]="showTipModal"
[tip]="currentOrder.tip.toString()"
[tipControl]="tipControl"
[subtotal]="currentOrder.subtotal"
(close)="onTipModalClose()"
/>
</div>
`
})
export class PosSystemComponent {
showTipModal = false;
currentOrder = {
items: [],
subtotal: 25.50,
tip: 0
};
tipControl = new FormControl<string | null>('0', [
Validators.required,
Validators.min(0)
]);
constructor(private orderService: OrderService) {}
getTotal(): number {
return this.currentOrder.subtotal + this.currentOrder.tip;
}
addTip() {
this.showTipModal = true;
}
onTipModalClose() {
this.showTipModal = false;
// Actualizar el pedido con la nueva propina
const newTip = parseFloat(this.tipControl.value || '0');
this.currentOrder.tip = newTip;
// Opcional: guardar en el servidor
this.orderService.updateOrderTip(this.currentOrder.id, newTip)
.subscribe(() => {
console.log('Propina actualizada en el servidor');
});
}
processPayment() {
const paymentData = {
...this.currentOrder,
total: this.getTotal()
};
this.orderService.processPayment(paymentData)
.subscribe(response => {
console.log('Pago procesado:', response);
});
}
}Ejemplo con Múltiples Pedidos
@Component({
selector: 'app-multi-order-tip',
standalone: true,
imports: [CbmTipModalComponent],
template: `
<div class="orders-container">
<div *ngFor="let order of orders; trackBy: trackByOrderId" class="order-card">
<div class="order-header">
<h4>Mesa {{ order.tableNumber }}</h4>
<span class="status" [class]="order.status">{{ order.status }}</span>
</div>
<div class="order-summary">
<div>Subtotal: {{ order.subtotal | currency }}</div>
<div>Propina: {{ order.tip | currency }}</div>
<div class="total">Total: {{ (order.subtotal + order.tip) | currency }}</div>
</div>
<button (click)="openTipModal(order)" class="btn-tip">
Modificar Propina
</button>
</div>
<cbm-tip-modal
[isOpen]="showTipModal"
[tip]="selectedOrder?.tip.toString()"
[tipControl]="tipControl"
[subtotal]="selectedOrder?.subtotal"
(close)="onTipModalClose()"
/>
</div>
`
})
export class MultiOrderTipComponent {
showTipModal = false;
selectedOrder: Order | null = null;
orders: Order[] = [
{ id: 1, tableNumber: 5, subtotal: 25.50, tip: 0, status: 'active' },
{ id: 2, tableNumber: 8, subtotal: 45.75, tip: 2.50, status: 'paid' },
{ id: 3, tableNumber: 12, subtotal: 18.25, tip: 0, status: 'active' }
];
tipControl = new FormControl<string | null>('0', [
Validators.required,
Validators.min(0)
]);
trackByOrderId(index: number, order: Order): number {
return order.id;
}
openTipModal(order: Order) {
this.selectedOrder = order;
this.tipControl.setValue(order.tip.toString());
this.showTipModal = true;
}
onTipModalClose() {
if (this.selectedOrder) {
const newTip = parseFloat(this.tipControl.value || '0');
this.selectedOrder.tip = newTip;
// Actualizar en el servidor
this.orderService.updateOrder(this.selectedOrder.id, {
tip: newTip
}).subscribe();
}
this.showTipModal = false;
this.selectedOrder = null;
}
}
interface Order {
id: number;
tableNumber: number;
subtotal: number;
tip: number;
status: 'active' | 'paid' | 'cancelled';
}⚠️ Manejo de Errores
Validaciones del Formulario
// Errores de porcentaje
percentageControl.errors = {
required: true, // Campo requerido
min: { min: 0 }, // Valor mínimo
max: { max: 10 } // Valor máximo (10%)
}
// Errores de valor
valueControl.errors = {
required: true, // Campo requerido
min: { min: 0 }, // Valor mínimo
invalid: 'La propina no puede ser mayor al 10% del subtotal'
}Estados de Validación
- Campos requeridos: Ambos campos son obligatorios
- Rango de porcentaje: 0% - 10% del subtotal
- Límite de valor: Máximo 10% del subtotal
- Precisión decimal: Hasta 2 decimales
Mensajes de Error
Los errores se muestran automáticamente usando CbmErrorTranslatePipe:
- Mensajes traducidos al español
- Animaciones de entrada/salida
- Estilos visuales consistentes
🔧 Configuración Avanzada
Personalización de Validaciones
// Crear un validador personalizado
customTipValidator(maxPercentage: number = 15): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
const subtotal = this.subtotal();
if (value == null) return { required: true };
const maxTip = (maxPercentage / 100) * subtotal;
if (Number(value) > maxTip) {
return {
invalid: `La propina no puede ser mayor al ${maxPercentage}% del subtotal`
};
}
return null;
};
}
// Usar el validador personalizado
this.valueControl.setValidators([
Validators.required,
Validators.min(0),
this.customTipValidator(15) // Máximo 15%
]);Integración con Directivas Personalizadas
// El componente incluye integración automática con:
- CbmNumberInputDirective: Formato numérico y validaciones
- CbmErrorTranslatePipe: Traducción de mensajes de errorConfiguración de Animaciones
// Animación personalizada para errores
export const errorAnimation = trigger('errorAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(-10px)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
]),
transition(':leave', [
animate('150ms ease-in', style({ opacity: 0, transform: 'translateY(-10px)' }))
])
]);📋 Dependencias
Peer Dependencies (Requeridas)
{
"@angular/common": ">=20.1.5",
"@angular/core": ">=20.1.5",
"@cbm-common/number-input-directive": "0.0.x",
"@cbm-common/error-translate-pipe": "0.0.x"
}Dependencias Internas
{
"tslib": "^2.3.0"
}Dependencias de Desarrollo
{
"tailwindcss": "^4.1.11",
"postcss": "^8.5.6",
"@tailwindcss/postcss": "^4.1.11"
}🛠️ Desarrollo
Estructura del Proyecto
tip-modal/
├── src/
│ ├── lib/
│ │ ├── tip-modal.component.ts # Componente principal
│ │ ├── tip-modal.component.html # Template del modal
│ │ ├── tip-modal.component.css # Estilos específicos
│ │ ├── styles.css # Estilos Tailwind
│ │ ├── animations.ts # Animaciones Angular
│ │ └── index.ts # Exportaciones
│ └── public-api.ts # API pública
├── ng-package.json # Configuración empaquetado
├── package.json # Dependencias
└── README.md # Esta documentaciónConstrucción
# Construir la librería
ng build tip-modal
# Construir en modo watch
ng build tip-modal --watch
# Construir para producción
ng build tip-modal --configuration productionPruebas
# Ejecutar pruebas unitarias
ng test tip-modal
# Ejecutar pruebas con coverage
ng test tip-modal --code-coverageDesarrollo con Tailwind
# Compilar estilos Tailwind
npm run tailwind
# Modo watch para desarrollo
npm run tailwind --watch🎯 Mejores Prácticas
1. Gestión de Estado
// Usar un servicio para gestionar el estado de propinas
@Injectable({ providedIn: 'root' })
export class TipService {
private tipSubject = new BehaviorSubject<number>(0);
tip$ = this.tipSubject.asObservable();
updateTip(tip: number) {
this.tipSubject.next(tip);
// Persistir en localStorage o servidor
localStorage.setItem('lastTip', tip.toString());
}
getLastTip(): number {
return parseFloat(localStorage.getItem('lastTip') || '0');
}
}2. Cálculos Complejos
// Para cálculos más complejos de propina
export class TipCalculator {
static calculateSuggestedTips(subtotal: number): TipSuggestion[] {
return [
{ percentage: 10, value: subtotal * 0.1, label: 'Excelente (10%)' },
{ percentage: 8, value: subtotal * 0.08, label: 'Muy bueno (8%)' },
{ percentage: 5, value: subtotal * 0.05, label: 'Bueno (5%)' }
];
}
static roundToNearestQuarter(amount: number): number {
return Math.round(amount * 4) / 4;
}
}
interface TipSuggestion {
percentage: number;
value: number;
label: string;
}3. Integración con Analytics
// Rastrear uso de propinas
export class TipAnalyticsService {
trackTipChange(orderId: string, oldTip: number, newTip: number) {
// Enviar evento a analytics
this.analytics.event('tip_changed', {
orderId,
oldTip,
newTip,
difference: newTip - oldTip
});
}
trackTipDistribution() {
// Analizar distribución de propinas
return this.http.get('/api/analytics/tips-distribution');
}
}🤝 Contribución
- Fork el repositorio
- Crea una rama para tu feature (
git checkout -b feature/nueva-funcionalidad) - Commit tus cambios (
git commit -am 'Agrega nueva funcionalidad') - Push a la rama (
git push origin feature/nueva-funcionalidad) - Abre un PullRequest
📄 Licencia
Este proyecto está bajo la Licencia MIT - ver el archivo LICENSE para más detalles.
📞 Soporte
Para soporte técnico o reportes de bugs, contacta al equipo de desarrollo de CBM:
- 📧 Email: [email protected]
- 💬 Slack: #desarrollo-cbm
- 📋 Issues: GitHub Issues
🔄 Changelog
v0.0.1
- ✅ Componente modal standalone para cálculo de propinas
- ✅ Formulario reactivo con validaciones personalizadas
- ✅ Cálculos automáticos entre porcentaje y valor
- ✅ Integración con directivas CBM (number-input, error-translate)
- ✅ Diseño responsive con Tailwind CSS
- ✅ Animaciones personalizadas para errores
- ✅ Encapsulación Shadow DOM
- ✅ Documentación completa en español
Nota: Este componente está optimizado para sistemas de punto de venta y restaurantes. Las validaciones aseguran que las propinas se mantengan dentro de límites razonables (máximo 10% del subtotal).
