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
JavaScript
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)">