UNPKG

ragasa-dynamic-form-renderer

Version:

Una librería Angular para renderizar formularios dinámicos basados en configuración JSON

1,042 lines (997 loc) 76.6 kB
import * as i0 from '@angular/core'; import { Injectable, EventEmitter, ViewChild, Output, Input, Component, forwardRef } from '@angular/core'; import * as i1 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i2 from '@angular/forms'; import { ReactiveFormsModule, NG_VALUE_ACCESSOR, FormControl, Validators } from '@angular/forms'; import { Subject, takeUntil } from 'rxjs'; import moment from 'moment-timezone'; class DynamicFormRendererService { constructor() { } /** * Valida un formulario dinámico y retorna los errores */ validateForm(form, questionary) { const errors = {}; questionary.sections.forEach(section => { section.questions.forEach(question => { const control = form.get(question.id); if (control?.invalid) { errors[question.id] = this.getValidationErrors(control, question.validators || []); } }); }); return errors; } /** * Extrae los datos del formulario en un formato limpio */ extractFormData(form) { const data = {}; Object.keys(form.controls).forEach(key => { const control = form.get(key); if (control?.value !== null && control?.value !== undefined && control?.value !== '') { data[key] = control.value; } }); return data; } /** * Prepara datos iniciales para un formulario basado en un questionary */ prepareInitialData(questionary, data) { const initialData = {}; questionary.sections.forEach(section => { section.questions.forEach(question => { if (data && data[question.id] !== undefined) { initialData[question.id] = data[question.id]; } else { // Valores por defecto según el tipo switch (question.type) { case 'multiselect': initialData[question.id] = []; break; case 'number': initialData[question.id] = null; break; case 'date': initialData[question.id] = null; break; default: initialData[question.id] = ''; } } }); }); return initialData; } /** * Valida si un questionary es válido */ validateQuestionary(questionary) { const errors = []; if (!questionary.id) { errors.push('El formulario debe tener un ID'); } if (!questionary.name || questionary.name.trim() === '') { errors.push('El formulario debe tener un nombre'); } if (!questionary.sections || questionary.sections.length === 0) { errors.push('El formulario debe tener al menos una sección'); } questionary.sections?.forEach((section, sectionIndex) => { if (!section.id) { errors.push(`La sección ${sectionIndex + 1} debe tener un ID`); } if (!section.name || section.name.trim() === '') { errors.push(`La sección ${sectionIndex + 1} debe tener un nombre`); } if (!section.questions || section.questions.length === 0) { errors.push(`La sección "${section.name}" debe tener al menos una pregunta`); } section.questions?.forEach((question, questionIndex) => { if (!question.id) { errors.push(`La pregunta ${questionIndex + 1} en la sección "${section.name}" debe tener un ID`); } if (!question.title || question.title.trim() === '') { errors.push(`La pregunta ${questionIndex + 1} en la sección "${section.name}" debe tener un título`); } if (!question.type) { errors.push(`La pregunta "${question.title}" debe tener un tipo`); } // Validar opciones para tipos de selección if (['select', 'multiselect', 'radio'].includes(question.type)) { if (!question.options || question.options.length === 0) { errors.push(`La pregunta "${question.title}" de tipo "${question.type}" debe tener opciones`); } } }); }); return { isValid: errors.length === 0, errors }; } /** * Obtiene mensajes de error de validación */ getValidationErrors(control, validators) { const errors = []; if (control.errors) { // Buscar mensajes personalizados primero validators.forEach(validator => { if (control.errors?.[validator.type]) { errors.push(validator.message); } }); // Mensajes por defecto if (control.errors['required'] && !errors.length) { errors.push('Este campo es requerido'); } if (control.errors['email'] && !errors.some(e => e.includes('email'))) { errors.push('Formato de email inválido'); } if (control.errors['minlength']) { errors.push(`Mínimo ${control.errors['minlength'].requiredLength} caracteres`); } if (control.errors['maxlength']) { errors.push(`Máximo ${control.errors['maxlength'].requiredLength} caracteres`); } if (control.errors['min']) { errors.push(`El valor mínimo es ${control.errors['min'].min}`); } if (control.errors['max']) { errors.push(`El valor máximo es ${control.errors['max'].max}`); } if (control.errors['pattern']) { errors.push('El formato no es válido'); } } return errors; } /** * Convierte datos del formulario a formato JSON */ exportFormData(formData) { return JSON.stringify(formData, null, 2); } /** * Importa datos desde JSON */ importFormData(jsonString) { try { return JSON.parse(jsonString); } catch (error) { console.error('Error parsing JSON data:', error); return null; } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormRendererService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormRendererService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormRendererService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class SignatureModalComponent { constructor() { this.isOpen = false; this.signatureSaved = new EventEmitter(); this.modalClosed = new EventEmitter(); this.isDrawing = false; this.lastX = 0; this.lastY = 0; this.isEmpty = true; } ngOnInit() { } ngAfterViewInit() { if (this.canvasRef) { this.setupCanvas(); if (this.initialSignature) { this.loadSignature(this.initialSignature); } } } setupCanvas() { this.canvas = this.canvasRef.nativeElement; this.ctx = this.canvas.getContext('2d'); // Establecer el tamaño del canvas const rect = this.canvas.getBoundingClientRect(); this.canvas.width = rect.width * window.devicePixelRatio; this.canvas.height = rect.height * window.devicePixelRatio; // Escalar el contexto para mantener la calidad en displays de alta densidad this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); // Configurar el estilo de dibujo this.ctx.strokeStyle = '#000000'; this.ctx.lineWidth = 2; this.ctx.lineCap = 'round'; this.ctx.lineJoin = 'round'; // Limpiar el canvas this.clearCanvas(); } getEventPos(event) { const rect = this.canvas.getBoundingClientRect(); let clientX, clientY; if (event instanceof TouchEvent) { event.preventDefault(); const touch = event.touches[0] || event.changedTouches[0]; clientX = touch.clientX; clientY = touch.clientY; } else { clientX = event.clientX; clientY = event.clientY; } return { x: clientX - rect.left, y: clientY - rect.top }; } startDrawing(event) { this.isDrawing = true; const pos = this.getEventPos(event); this.lastX = pos.x; this.lastY = pos.y; } draw(event) { if (!this.isDrawing) return; const pos = this.getEventPos(event); this.ctx.beginPath(); this.ctx.moveTo(this.lastX, this.lastY); this.ctx.lineTo(pos.x, pos.y); this.ctx.stroke(); this.lastX = pos.x; this.lastY = pos.y; this.isEmpty = false; } stopDrawing() { this.isDrawing = false; } clearCanvas() { this.ctx.fillStyle = '#ffffff'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.isEmpty = true; } loadSignature(base64) { const img = new Image(); img.onload = () => { this.clearCanvas(); this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height); this.isEmpty = false; }; img.src = base64; } saveSignature() { if (this.isEmpty) return; const base64 = this.canvas.toDataURL('image/png', 0.8); this.signatureSaved.emit(base64); this.closeModal(); } closeModal() { this.modalClosed.emit(); } onOverlayClick(event) { if (event.target === event.currentTarget) { this.closeModal(); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignatureModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignatureModalComponent, isStandalone: true, selector: "lib-signature-modal", inputs: { isOpen: "isOpen", initialSignature: "initialSignature" }, outputs: { signatureSaved: "signatureSaved", modalClosed: "modalClosed" }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["signatureCanvas"], descendants: true }], ngImport: i0, template: ` <div class="signature-modal-overlay" (click)="onOverlayClick($event)"> <div class="signature-modal-container"> <div class="signature-modal-header"> <h3 class="signature-modal-title">Firma Digital</h3> <button type="button" class="signature-modal-close" (click)="closeModal()"> × </button> </div> <div class="signature-modal-body"> <div class="signature-canvas-container"> <canvas #signatureCanvas class="signature-canvas" (mousedown)="startDrawing($event)" (mousemove)="draw($event)" (mouseup)="stopDrawing()" (mouseleave)="stopDrawing()" (touchstart)="startDrawing($event)" (touchmove)="draw($event)" (touchend)="stopDrawing()"> </canvas> <div class="signature-placeholder" *ngIf="isEmpty"> Firma aquí </div> </div> </div> <div class="signature-modal-footer"> <button type="button" class="btn-secondary" (click)="clearCanvas()"> Limpiar </button> <button type="button" class="btn-secondary" (click)="closeModal()"> Cancelar </button> <button type="button" class="btn-primary" [disabled]="isEmpty" (click)="saveSignature()"> Guardar Firma </button> </div> </div> </div> `, isInline: true, styles: [".signature-modal-overlay{position:fixed;inset:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.signature-modal-container{background:#fff;border-radius:8px;box-shadow:0 10px 25px #0003;max-width:600px;width:90%;max-height:80vh;display:flex;flex-direction:column}.signature-modal-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border-bottom:1px solid #e5e7eb}.signature-modal-title{margin:0;font-size:1.25rem;font-weight:600;color:#374151}.signature-modal-close{background:none;border:none;font-size:1.5rem;color:#6b7280;cursor:pointer;padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center;border-radius:50%;transition:background-color .15s}.signature-modal-close:hover{background-color:#f3f4f6}.signature-modal-body{padding:1.5rem;flex-grow:1}.signature-canvas-container{position:relative;border:2px solid #e5e7eb;border-radius:8px;background:#fafafa;height:300px;width:100%}.signature-canvas{width:100%;height:100%;cursor:crosshair;border-radius:6px}.signature-placeholder{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#9ca3af;font-size:1.125rem;font-weight:500;pointer-events:none}.signature-modal-footer{display:flex;gap:.75rem;padding:1rem 1.5rem;border-top:1px solid #e5e7eb;justify-content:flex-end}.btn-primary{background-color:#3b82f6;color:#fff;border:none;padding:.5rem 1rem;border-radius:6px;font-weight:500;cursor:pointer;transition:background-color .15s}.btn-primary:hover:not(:disabled){background-color:#2563eb}.btn-primary:disabled{background-color:#9ca3af;cursor:not-allowed}.btn-secondary{background-color:#f3f4f6;color:#374151;border:1px solid #d1d5db;padding:.5rem 1rem;border-radius:6px;font-weight:500;cursor:pointer;transition:background-color .15s}.btn-secondary:hover{background-color:#e5e7eb}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignatureModalComponent, decorators: [{ type: Component, args: [{ selector: 'lib-signature-modal', standalone: true, imports: [CommonModule], template: ` <div class="signature-modal-overlay" (click)="onOverlayClick($event)"> <div class="signature-modal-container"> <div class="signature-modal-header"> <h3 class="signature-modal-title">Firma Digital</h3> <button type="button" class="signature-modal-close" (click)="closeModal()"> × </button> </div> <div class="signature-modal-body"> <div class="signature-canvas-container"> <canvas #signatureCanvas class="signature-canvas" (mousedown)="startDrawing($event)" (mousemove)="draw($event)" (mouseup)="stopDrawing()" (mouseleave)="stopDrawing()" (touchstart)="startDrawing($event)" (touchmove)="draw($event)" (touchend)="stopDrawing()"> </canvas> <div class="signature-placeholder" *ngIf="isEmpty"> Firma aquí </div> </div> </div> <div class="signature-modal-footer"> <button type="button" class="btn-secondary" (click)="clearCanvas()"> Limpiar </button> <button type="button" class="btn-secondary" (click)="closeModal()"> Cancelar </button> <button type="button" class="btn-primary" [disabled]="isEmpty" (click)="saveSignature()"> Guardar Firma </button> </div> </div> </div> `, styles: [".signature-modal-overlay{position:fixed;inset:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.signature-modal-container{background:#fff;border-radius:8px;box-shadow:0 10px 25px #0003;max-width:600px;width:90%;max-height:80vh;display:flex;flex-direction:column}.signature-modal-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border-bottom:1px solid #e5e7eb}.signature-modal-title{margin:0;font-size:1.25rem;font-weight:600;color:#374151}.signature-modal-close{background:none;border:none;font-size:1.5rem;color:#6b7280;cursor:pointer;padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center;border-radius:50%;transition:background-color .15s}.signature-modal-close:hover{background-color:#f3f4f6}.signature-modal-body{padding:1.5rem;flex-grow:1}.signature-canvas-container{position:relative;border:2px solid #e5e7eb;border-radius:8px;background:#fafafa;height:300px;width:100%}.signature-canvas{width:100%;height:100%;cursor:crosshair;border-radius:6px}.signature-placeholder{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#9ca3af;font-size:1.125rem;font-weight:500;pointer-events:none}.signature-modal-footer{display:flex;gap:.75rem;padding:1rem 1.5rem;border-top:1px solid #e5e7eb;justify-content:flex-end}.btn-primary{background-color:#3b82f6;color:#fff;border:none;padding:.5rem 1rem;border-radius:6px;font-weight:500;cursor:pointer;transition:background-color .15s}.btn-primary:hover:not(:disabled){background-color:#2563eb}.btn-primary:disabled{background-color:#9ca3af;cursor:not-allowed}.btn-secondary{background-color:#f3f4f6;color:#374151;border:1px solid #d1d5db;padding:.5rem 1rem;border-radius:6px;font-weight:500;cursor:pointer;transition:background-color .15s}.btn-secondary:hover{background-color:#e5e7eb}\n"] }] }], propDecorators: { isOpen: [{ type: Input }], initialSignature: [{ type: Input }], signatureSaved: [{ type: Output }], modalClosed: [{ type: Output }], canvasRef: [{ type: ViewChild, args: ['signatureCanvas', { static: false }] }] } }); class RecursiveQuestionRendererComponent { constructor() { this.questions = []; this.onChange = (value) => { }; this.onTouched = () => { }; // Propiedades para el modal de firma this.isSignatureModalOpen = false; this.currentSignatureQuestionId = ''; this.currentSignatureValue = ''; } ngOnInit() { // El componente recibe el FormGroup desde el padre } // ControlValueAccessor methods writeValue(value) { // No necesitamos implementar esto ya que usamos el FormGroup del padre } registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouched = fn; } getSortedQuestions() { return [...this.questions].sort((a, b) => a.order - b.order); } getVisibleQuestions() { return this.getSortedQuestions().filter(question => this.isQuestionVisible(question)); } isQuestionVisible(question) { // Si la pregunta no tiene target, siempre es visible if (!question.targetQuestionId) { return true; } // Obtener el valor del formulario para la pregunta target const targetValue = this.parentFormGroup.get(question.targetQuestionId)?.value; // Si no hay valor seleccionado, la pregunta está oculta if (targetValue === null || targetValue === undefined || targetValue === '') { return false; } // Evaluar según el tipo de target const targetType = question.targetType || 'select'; // Backward compatibility if (targetType === 'select') { // Lógica original para selects if (!question.targetValues || question.targetValues.length === 0) { return true; } return question.targetValues.includes(targetValue); } else if (targetType === 'text' || targetType === 'number') { // Nueva lógica para text y number if (!question.targetComparison || question.targetComparisonValue === undefined) { return true; } return this.evaluateComparison(targetValue, question.targetComparison, question.targetComparisonValue, targetType); } return true; } evaluateComparison(actualValue, comparison, expectedValue, targetType) { // Convertir valores según el tipo let actual = actualValue; let expected = expectedValue; if (targetType === 'number') { actual = parseFloat(actual); expected = parseFloat(expected); // Si no son números válidos, retornar false if (isNaN(actual) || isNaN(expected)) { return false; } } else { // Para text, comparar como strings actual = String(actual).toLowerCase(); expected = String(expected).toLowerCase(); } switch (comparison) { case 'equal': return actual === expected; case 'greater': return actual > expected; case 'less': return actual < expected; case 'greaterEqual': return actual >= expected; case 'lessEqual': return actual <= expected; default: return true; } } trackByQuestion(index, question) { return question.id; } getFormControl(questionId) { if (!this.parentFormGroup) { return null; } return this.parentFormGroup.get(questionId); } isRequired(question) { return question.required === true || question.validators?.some(v => v.type === 'required') === true; } getInputClasses(question) { const baseClasses = this.config?.customCssClasses?.input || 'form-input'; const control = this.getFormControl(question.id); if (control?.invalid && control?.touched) { return `${baseClasses} error`; } return baseClasses; } getErrorMessages(question) { const control = this.getFormControl(question.id); const errors = []; if (control?.errors) { // Buscar mensajes personalizados en los validators question.validators?.forEach(validator => { if (control.errors?.[validator.type]) { errors.push(validator.message); } }); // Mensajes por defecto para errores no cubiertos if (control.errors['required'] && !errors.length) { errors.push('Este campo es requerido'); } if (control.errors['email'] && !errors.some(e => e.includes('email'))) { errors.push('Formato de email inválido'); } if (control.errors['minlength']) { errors.push(`Mínimo ${control.errors['minlength'].requiredLength} caracteres`); } if (control.errors['maxlength']) { errors.push(`Máximo ${control.errors['maxlength'].requiredLength} caracteres`); } if (control.errors['min']) { errors.push(`El valor mínimo es ${control.errors['min'].min}`); } if (control.errors['max']) { errors.push(`El valor máximo es ${control.errors['max'].max}`); } if (control.errors['invalidFileType']) { errors.push('Solo se permiten archivos de imagen (JPG, PNG, GIF, WebP, SVG)'); } if (control.errors['fileTooLarge']) { errors.push('El archivo es demasiado grande. Tamaño máximo: 16MB'); } if (control.errors['fileReadError']) { errors.push('Error al leer el archivo. Intenta con otro archivo.'); } } return errors; } onCheckboxChange(questionId, value, event) { const control = this.getFormControl(questionId); if (!control) return; const currentValue = control.value || []; if (event.target.checked) { if (!currentValue.includes(value)) { control.setValue([...currentValue, value]); } } else { control.setValue(currentValue.filter(v => v !== value)); } } onFileChange(questionId, event) { const control = this.getFormControl(questionId); if (!control) return; const file = event.target.files[0]; if (file) { // Verificar si el archivo es una imagen const validImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; // Tamaño máximo: 16MB (16 * 1024 * 1024 bytes) const maxSize = 16 * 1024 * 1024; let errors = {}; // Validar tipo de archivo if (!validImageTypes.includes(file.type)) { errors.invalidFileType = true; } // Validar tamaño de archivo if (file.size > maxSize) { errors.fileTooLarge = true; } if (Object.keys(errors).length === 0) { // Archivo válido - convertir a base64 const reader = new FileReader(); reader.onload = () => { const base64String = reader.result; control.setValue(base64String); control.setErrors(null); }; reader.onerror = () => { control.setErrors({ fileReadError: true }); control.setValue(null); event.target.value = ''; }; reader.readAsDataURL(file); } else { // Archivo no válido, establecer errores alert("Archivo no válido"); control.setErrors(errors); control.setValue(null); // Limpiar el input event.target.value = ''; } } else { control.setValue(null); } } getFilteredOptions(question) { if (!question.options) return []; // Si no es un select dependiente, devolver todas las opciones if (!question.parentSelectId) { return question.options; } // Es un select dependiente, filtrar por la selección del padre const parentControl = this.getFormControl(question.parentSelectId); const parentSelectedValue = parentControl?.value; if (!parentSelectedValue) { // Si no hay selección en el padre, no mostrar opciones return []; } // Filtrar opciones que corresponden a la selección del padre const filteredOptions = question.options.filter(option => option.parentOptionValues && option.parentOptionValues.includes(parentSelectedValue)); // Si cambió la selección del padre, resetear el valor del select hijo si ya no es válido const currentControl = this.getFormControl(question.id); const currentValue = currentControl?.value; if (currentValue && !filteredOptions.some(opt => opt.value === currentValue)) { currentControl?.setValue(''); } return filteredOptions; } // Métodos para firma digital openSignatureModal(questionId) { this.currentSignatureQuestionId = questionId; const control = this.getFormControl(questionId); this.currentSignatureValue = control?.value || ''; this.isSignatureModalOpen = true; } closeSignatureModal() { this.isSignatureModalOpen = false; this.currentSignatureQuestionId = ''; this.currentSignatureValue = ''; } onSignatureSaved(base64Signature) { const control = this.getFormControl(this.currentSignatureQuestionId); if (control) { control.setValue(base64Signature); control.markAsTouched(); // Limpiar cualquier error previo control.setErrors(null); } } getSignaturePreview(questionId) { const control = this.getFormControl(questionId); const value = control?.value; // Verificar si es un base64 válido de imagen if (value && typeof value === 'string' && value.startsWith('data:image/')) { return value; } return null; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RecursiveQuestionRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: RecursiveQuestionRendererComponent, isStandalone: true, selector: "lib-recursive-question-renderer", inputs: { questions: "questions", config: "config", parentFormGroup: "parentFormGroup", formValues: "formValues" }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RecursiveQuestionRendererComponent), multi: true } ], ngImport: i0, template: ` <div class="recursive-question-container" [formGroup]="parentFormGroup"> <div *ngFor="let question of getVisibleQuestions(); trackBy: trackByQuestion"> <!-- Si es una sección anidada --> <div *ngIf="question.type === 'section'" [class]="config?.customCssClasses?.section || 'bg-gray-50 rounded-lg p-4 mb-4 border border-gray-200'"> <h4 [class]="config?.customCssClasses?.sectionHeader || 'text-lg font-semibold mb-3 text-gray-800 border-b pb-2'"> {{ question.title }} </h4> <!-- Renderizado recursivo de sub-preguntas --> <lib-recursive-question-renderer *ngIf="question.questions && question.questions.length > 0" [questions]="question.questions" [config]="config" [parentFormGroup]="parentFormGroup" [formValues]="formValues"> </lib-recursive-question-renderer> <div *ngIf="!question.questions || question.questions.length === 0" class="text-gray-500 text-sm italic py-2"> Esta sección no tiene preguntas </div> </div> <!-- Si es una pregunta normal --> <div *ngIf="question.type !== 'section'" [class]="config?.customCssClasses?.questionContainer || 'mb-4'"> <label [for]="question.id" [class]="config?.customCssClasses?.label || 'block text-sm font-medium text-gray-700 mb-2'"> {{ question.title }} <span *ngIf="isRequired(question)" class="text-red-500 ml-1">*</span> </label> <div [ngSwitch]="question.type"> <!-- Text Input --> <input *ngSwitchCase="'text'" type="text" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)" [placeholder]="question.placeholder || 'Ingresa texto...'"> <!-- Email Input --> <input *ngSwitchCase="'email'" type="email" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)" [placeholder]="question.placeholder || 'correo@ejemplo.com'"> <!-- Number Input --> <input *ngSwitchCase="'number'" type="number" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)" [placeholder]="question.placeholder || 'Ingresa un número...'"> <!-- Textarea --> <textarea *ngSwitchCase="'textarea'" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question) + ' min-h-20 resize-vertical'" [placeholder]="question.placeholder || 'Ingresa texto largo...'"> </textarea> <!-- Date Input --> <input *ngSwitchCase="'date'" type="date" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)"> <!-- Select --> <select *ngSwitchCase="'select'" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)"> <option value="">{{ question.placeholder || 'Selecciona una opción...' }}</option> <option *ngFor="let option of getFilteredOptions(question)" [value]="option.value"> {{ option.label }} </option> </select> <!-- Multiple Select (Checkboxes) --> <div *ngSwitchCase="'multiselect'" [class]="config?.customCssClasses?.checkboxGroup || 'space-y-2'"> <p class="text-sm text-gray-600">{{ question.placeholder || 'Selección múltiple...' }}</p> <div *ngFor="let option of getFilteredOptions(question); let i = index" class="flex items-center gap-2"> <input type="checkbox" [id]="question.id + '_' + i" [value]="option.value" (change)="onCheckboxChange(question.id, option.value, $event)" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"> <label [for]="question.id + '_' + i" class="text-sm text-gray-700">{{ option.label }}</label> </div> </div> <!-- Radio Buttons --> <div *ngSwitchCase="'radio'" [class]="config?.customCssClasses?.radioGroup || 'space-y-2'"> <p class="text-sm text-gray-600">{{ question.placeholder || 'Selecciona una opción...' }}</p> <div *ngFor="let option of getFilteredOptions(question); let i = index" class="flex items-center gap-2"> <input type="radio" [id]="question.id + '_' + i" [name]="question.id" [value]="option.value" [formControlName]="question.id" class="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"> <label [for]="question.id + '_' + i" class="text-sm text-gray-700">{{ option.label }}</label> </div> </div> <!-- File Input --> <div *ngSwitchCase="'file'"> <input type="file" [id]="question.id" (change)="onFileChange(question.id, $event)" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"> <p class="mt-1 text-xs text-gray-500">Solo se permiten archivos de imagen (JPG, PNG, GIF, WebP, SVG) - Máximo 16MB</p> </div> <!-- Signature --> <div *ngSwitchCase="'signature'"> <!-- Área de firma --> <div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors" (click)="openSignatureModal(question.id)"> <!-- Si no hay firma --> <div *ngIf="!getSignaturePreview(question.id)" class="signature-empty"> <svg class="w-12 h-12 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path> </svg> <p class="text-gray-600 font-medium">Haz clic para firmar</p> <small class="text-gray-500">Área de firma digital</small> </div> <!-- Si hay firma --> <div *ngIf="getSignaturePreview(question.id)" class="signature-preview"> <img [src]="getSignaturePreview(question.id)" alt="Firma digital" class="max-w-full max-h-32 mx-auto border rounded"> <p class="text-green-600 font-medium mt-2">Firma capturada</p> <small class="text-gray-500">Haz clic para editar</small> </div> </div> <input type="hidden" [formControlName]="question.id"> </div> </div> <!-- Error Messages --> <div *ngIf="getFormControl(question.id)?.invalid && getFormControl(question.id)?.touched" [class]="config?.customCssClasses?.error || 'text-red-500 text-sm mt-1'"> <div *ngFor="let error of getErrorMessages(question)"> {{ error }} </div> </div> </div> </div> </div> <!-- Modal de firma --> <lib-signature-modal *ngIf="isSignatureModalOpen" [isOpen]="isSignatureModalOpen" [initialSignature]="currentSignatureValue" (signatureSaved)="onSignatureSaved($event)" (modalClosed)="closeSignatureModal()"> </lib-signature-modal> `, isInline: true, styles: [".recursive-question-container{width:100%}.form-input{width:100%;padding:.5rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px #3b82f61a}.form-input.error{border-color:#ef4444}.form-input.error:focus{border-color:#ef4444;box-shadow:0 0 0 3px #ef44441a}.recursive-question-container>div>.recursive-question-container{margin-left:1rem;border-left:2px solid #e5e7eb;padding-left:1rem}.signature-empty svg{transition:transform .2s ease}.signature-empty:hover svg{transform:scale(1.1)}.signature-preview img{transition:transform .2s ease}.signature-preview:hover img{transform:scale(1.05)}\n"], dependencies: [{ kind: "component", type: RecursiveQuestionRendererComponent, selector: "lib-recursive-question-renderer", inputs: ["questions", "config", "parentFormGroup", "formValues"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i2.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i2.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i2.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i2.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: SignatureModalComponent, selector: "lib-signature-modal", inputs: ["isOpen", "initialSignature"], outputs: ["signatureSaved", "modalClosed"] }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RecursiveQuestionRendererComponent, decorators: [{ type: Component, args: [{ selector: 'lib-recursive-question-renderer', standalone: true, imports: [CommonModule, ReactiveFormsModule, SignatureModalComponent], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RecursiveQuestionRendererComponent), multi: true } ], template: ` <div class="recursive-question-container" [formGroup]="parentFormGroup"> <div *ngFor="let question of getVisibleQuestions(); trackBy: trackByQuestion"> <!-- Si es una sección anidada --> <div *ngIf="question.type === 'section'" [class]="config?.customCssClasses?.section || 'bg-gray-50 rounded-lg p-4 mb-4 border border-gray-200'"> <h4 [class]="config?.customCssClasses?.sectionHeader || 'text-lg font-semibold mb-3 text-gray-800 border-b pb-2'"> {{ question.title }} </h4> <!-- Renderizado recursivo de sub-preguntas --> <lib-recursive-question-renderer *ngIf="question.questions && question.questions.length > 0" [questions]="question.questions" [config]="config" [parentFormGroup]="parentFormGroup" [formValues]="formValues"> </lib-recursive-question-renderer> <div *ngIf="!question.questions || question.questions.length === 0" class="text-gray-500 text-sm italic py-2"> Esta sección no tiene preguntas </div> </div> <!-- Si es una pregunta normal --> <div *ngIf="question.type !== 'section'" [class]="config?.customCssClasses?.questionContainer || 'mb-4'"> <label [for]="question.id" [class]="config?.customCssClasses?.label || 'block text-sm font-medium text-gray-700 mb-2'"> {{ question.title }} <span *ngIf="isRequired(question)" class="text-red-500 ml-1">*</span> </label> <div [ngSwitch]="question.type"> <!-- Text Input --> <input *ngSwitchCase="'text'" type="text" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)" [placeholder]="question.placeholder || 'Ingresa texto...'"> <!-- Email Input --> <input *ngSwitchCase="'email'" type="email" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)" [placeholder]="question.placeholder || 'correo@ejemplo.com'"> <!-- Number Input --> <input *ngSwitchCase="'number'" type="number" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)" [placeholder]="question.placeholder || 'Ingresa un número...'"> <!-- Textarea --> <textarea *ngSwitchCase="'textarea'" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question) + ' min-h-20 resize-vertical'" [placeholder]="question.placeholder || 'Ingresa texto largo...'"> </textarea> <!-- Date Input --> <input *ngSwitchCase="'date'" type="date" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)"> <!-- Select --> <select *ngSwitchCase="'select'" [id]="question.id" [formControlName]="question.id" [class]="getInputClasses(question)"> <option value="">{{ question.placeholder || 'Selecciona una opción...' }}</option> <option *ngFor="let option of getFilteredOptions(question)" [value]="option.value"> {{ option.label }} </option> </select> <!-- Multiple Select (Checkboxes) --> <div *ngSwitchCase="'multiselect'" [class]="config?.customCssClasses?.checkboxGroup || 'space-y-2'"> <p class="text-sm text-gray-600">{{ question.placeholder || 'Selección múltiple...' }}</p> <div *ngFor="let option of getFilteredOptions(question); let i = index" class="flex items-center gap-2"> <input type="checkbox" [id]="question.id + '_' + i" [value]="option.value" (change)="onCheckboxChange(question.id, option.value, $event)" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"> <label [for]="question.id + '_' + i" class="text-sm text-gray-700">{{ option.label }}</label> </div> </div> <!-- Radio Buttons --> <div *ngSwitchCase="'radio'" [class]="config?.customCssClasses?.radioGroup || 'space-y-2'"> <p class="text-sm text-gray-600">{{ question.placeholder || 'Selecciona una opción...' }}</p> <div *ngFor="let option of getFilteredOptions(question); let i = index" class="flex items-center gap-2"> <input type="radio" [id]="question.id + '_' + i" [name]="question.id" [value]="option.value" [formControlName]="question.id" class="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"> <label [for]="question.id + '_' + i" class="text-sm text-gray-700">{{ option.label }}</label> </div> </div> <!-- File Input --> <div *ngSwitchCase="'file'"> <input type="file" [id]="question.id" (change)="onFileChange(question.id, $event)" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"> <p class="mt-1 text-xs text-gray-500">Solo se permiten archivos de imagen (JPG, PNG, GIF, WebP, SVG) - Máximo 16MB</p> </div> <!-- Signature --> <div *ngSwitchCase="'signature'"> <!-- Área de firma --> <div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors" (click)="openSignatureModal(question.id)">