valtech-components 4.0.987 → 4.0.989

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/esm2022/lib/components/organisms/attachment-uploader/attachment-uploader.component.mjs +41 -2
  2. package/esm2022/lib/components/organisms/attachment-uploader/types.mjs +1 -1
  3. package/esm2022/lib/components/organisms/request-review-panel/request-review-panel.component.mjs +316 -481
  4. package/esm2022/lib/components/organisms/request-review-panel/request-review-panel.i18n.mjs +21 -0
  5. package/esm2022/lib/components/organisms/request-review-panel/types.mjs +2 -0
  6. package/esm2022/lib/components/organisms/survey-response/survey-response.component.mjs +21 -1
  7. package/esm2022/lib/components/organisms/survey-response/survey-response.i18n.mjs +5 -1
  8. package/esm2022/lib/components/organisms/survey-response/types.mjs +1 -1
  9. package/esm2022/lib/services/requests/types.mjs +1 -1
  10. package/esm2022/lib/version.mjs +2 -2
  11. package/esm2022/public-api.mjs +3 -2
  12. package/fesm2022/valtech-components.mjs +699 -780
  13. package/fesm2022/valtech-components.mjs.map +1 -1
  14. package/lib/components/organisms/attachment-uploader/attachment-uploader.component.d.ts +14 -0
  15. package/lib/components/organisms/attachment-uploader/types.d.ts +30 -0
  16. package/lib/components/organisms/request-review-panel/request-review-panel.component.d.ts +72 -76
  17. package/lib/components/organisms/request-review-panel/request-review-panel.i18n.d.ts +2 -0
  18. package/lib/components/organisms/request-review-panel/types.d.ts +73 -0
  19. package/lib/components/organisms/survey-response/survey-response.component.d.ts +2 -0
  20. package/lib/components/organisms/survey-response/types.d.ts +6 -0
  21. package/lib/services/requests/types.d.ts +6 -0
  22. package/lib/version.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/public-api.d.ts +2 -1
@@ -70,7 +70,7 @@ import fixWebmDuration from 'fix-webm-duration';
70
70
  * Current version of valtech-components.
71
71
  * This is automatically updated during the publish process.
72
72
  */
73
- const VERSION = '4.0.987';
73
+ const VERSION = '4.0.989';
74
74
 
75
75
  function evaluateValtechAccess(rule, context, features = {}, visitedFeatures = new Set()) {
76
76
  if (rule == null)
@@ -37047,6 +37047,260 @@ const DEFAULT_FEEDBACK_TYPE_OPTIONS = [
37047
37047
  */
37048
37048
  // Configuration
37049
37049
 
37050
+ /**
37051
+ * Default values for image processing
37052
+ */
37053
+ const IMAGE_DEFAULTS = {
37054
+ maxWidth: 800,
37055
+ maxHeight: 800,
37056
+ quality: 0.8,
37057
+ mimeType: 'image/jpeg',
37058
+ maxSize: 10 * 1024 * 1024, // 10MB
37059
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
37060
+ thumbnailSize: 150,
37061
+ };
37062
+
37063
+ /**
37064
+ * ImageService
37065
+ *
37066
+ * Service for image processing including compression, thumbnails, cropping and validation.
37067
+ * Uses HTML Canvas for all operations - no external dependencies.
37068
+ *
37069
+ * @example
37070
+ * ```typescript
37071
+ * const imageService = inject(ImageService);
37072
+ *
37073
+ * // Compress an image
37074
+ * const compressed = await imageService.compress(file, { maxWidth: 800, quality: 0.8 });
37075
+ *
37076
+ * // Generate thumbnail
37077
+ * const thumb = await imageService.thumbnail(file, 150);
37078
+ *
37079
+ * // Validate before processing
37080
+ * const validation = imageService.validate(file, { maxSize: 5 * 1024 * 1024 });
37081
+ * if (!validation.valid) {
37082
+ * console.error(validation.message);
37083
+ * }
37084
+ * ```
37085
+ */
37086
+ class ImageService {
37087
+ /**
37088
+ * Compress an image maintaining aspect ratio
37089
+ * @param file - File or Blob to compress
37090
+ * @param options - Compression options
37091
+ * @returns Promise with processed image data
37092
+ */
37093
+ async compress(file, options) {
37094
+ const opts = {
37095
+ maxWidth: options?.maxWidth ?? IMAGE_DEFAULTS.maxWidth,
37096
+ maxHeight: options?.maxHeight ?? IMAGE_DEFAULTS.maxHeight,
37097
+ quality: options?.quality ?? IMAGE_DEFAULTS.quality,
37098
+ mimeType: options?.mimeType ?? IMAGE_DEFAULTS.mimeType,
37099
+ };
37100
+ const img = await this.loadImage(file);
37101
+ const { width, height } = this.calculateDimensions(img.width, img.height, opts.maxWidth, opts.maxHeight);
37102
+ const canvas = document.createElement('canvas');
37103
+ canvas.width = width;
37104
+ canvas.height = height;
37105
+ const ctx = canvas.getContext('2d');
37106
+ ctx.drawImage(img, 0, 0, width, height);
37107
+ const blob = await this.canvasToBlob(canvas, opts.mimeType, opts.quality);
37108
+ const dataUrl = canvas.toDataURL(opts.mimeType, opts.quality);
37109
+ return {
37110
+ blob,
37111
+ dataUrl,
37112
+ width,
37113
+ height,
37114
+ size: blob.size,
37115
+ };
37116
+ }
37117
+ /**
37118
+ * Generate a square thumbnail from an image
37119
+ * @param file - File or Blob to process
37120
+ * @param size - Thumbnail size in pixels (default: 150)
37121
+ * @returns Promise with processed thumbnail
37122
+ */
37123
+ async thumbnail(file, size) {
37124
+ const thumbSize = size ?? IMAGE_DEFAULTS.thumbnailSize;
37125
+ const img = await this.loadImage(file);
37126
+ // Calculate square crop from center
37127
+ const minDim = Math.min(img.width, img.height);
37128
+ const cropX = (img.width - minDim) / 2;
37129
+ const cropY = (img.height - minDim) / 2;
37130
+ const canvas = document.createElement('canvas');
37131
+ canvas.width = thumbSize;
37132
+ canvas.height = thumbSize;
37133
+ const ctx = canvas.getContext('2d');
37134
+ ctx.drawImage(img, cropX, cropY, minDim, minDim, 0, 0, thumbSize, thumbSize);
37135
+ const blob = await this.canvasToBlob(canvas, IMAGE_DEFAULTS.mimeType, 0.7 // Lower quality for thumbnails
37136
+ );
37137
+ const dataUrl = canvas.toDataURL(IMAGE_DEFAULTS.mimeType, 0.7);
37138
+ return {
37139
+ blob,
37140
+ dataUrl,
37141
+ width: thumbSize,
37142
+ height: thumbSize,
37143
+ size: blob.size,
37144
+ };
37145
+ }
37146
+ /**
37147
+ * Crop an image with specific coordinates
37148
+ * @param file - File or Blob to crop
37149
+ * @param cropData - Crop coordinates and dimensions
37150
+ * @param options - Optional compression options for output
37151
+ * @returns Promise with cropped image
37152
+ */
37153
+ async crop(file, cropData, options) {
37154
+ const img = await this.loadImage(file);
37155
+ const opts = {
37156
+ quality: options?.quality ?? IMAGE_DEFAULTS.quality,
37157
+ mimeType: options?.mimeType ?? IMAGE_DEFAULTS.mimeType,
37158
+ };
37159
+ const canvas = document.createElement('canvas');
37160
+ canvas.width = cropData.width;
37161
+ canvas.height = cropData.height;
37162
+ const ctx = canvas.getContext('2d');
37163
+ ctx.drawImage(img, cropData.x, cropData.y, cropData.width, cropData.height, 0, 0, cropData.width, cropData.height);
37164
+ // Apply max dimensions if specified
37165
+ if (options?.maxWidth || options?.maxHeight) {
37166
+ return this.compress(await this.canvasToBlob(canvas, opts.mimeType, 1), options);
37167
+ }
37168
+ const blob = await this.canvasToBlob(canvas, opts.mimeType, opts.quality);
37169
+ const dataUrl = canvas.toDataURL(opts.mimeType, opts.quality);
37170
+ return {
37171
+ blob,
37172
+ dataUrl,
37173
+ width: cropData.width,
37174
+ height: cropData.height,
37175
+ size: blob.size,
37176
+ };
37177
+ }
37178
+ /**
37179
+ * Validate an image file before processing
37180
+ * @param file - File to validate
37181
+ * @param options - Validation options
37182
+ * @returns Validation result with error details if invalid
37183
+ */
37184
+ validate(file, options) {
37185
+ const opts = {
37186
+ maxSize: options?.maxSize ?? IMAGE_DEFAULTS.maxSize,
37187
+ allowedTypes: options?.allowedTypes ?? IMAGE_DEFAULTS.allowedTypes,
37188
+ };
37189
+ // Check file type
37190
+ if (!opts.allowedTypes.includes(file.type)) {
37191
+ return {
37192
+ valid: false,
37193
+ error: 'invalidType',
37194
+ message: `Formato no válido. Usa: ${opts.allowedTypes.map(t => t.split('/')[1].toUpperCase()).join(', ')}`,
37195
+ };
37196
+ }
37197
+ // Check file size
37198
+ if (file.size > opts.maxSize) {
37199
+ const maxMB = Math.round(opts.maxSize / (1024 * 1024));
37200
+ return {
37201
+ valid: false,
37202
+ error: 'fileTooLarge',
37203
+ message: `La imagen es muy grande. Máximo ${maxMB}MB`,
37204
+ };
37205
+ }
37206
+ return { valid: true };
37207
+ }
37208
+ /**
37209
+ * Validate image dimensions (async - requires loading image)
37210
+ * @param file - File to validate
37211
+ * @param options - Validation options with minWidth/minHeight
37212
+ * @returns Promise with validation result
37213
+ */
37214
+ async validateDimensions(file, options) {
37215
+ const img = await this.loadImage(file);
37216
+ if (options.minWidth && img.width < options.minWidth) {
37217
+ return {
37218
+ valid: false,
37219
+ error: 'imageTooSmall',
37220
+ message: `La imagen debe tener al menos ${options.minWidth}px de ancho`,
37221
+ };
37222
+ }
37223
+ if (options.minHeight && img.height < options.minHeight) {
37224
+ return {
37225
+ valid: false,
37226
+ error: 'imageTooSmall',
37227
+ message: `La imagen debe tener al menos ${options.minHeight}px de alto`,
37228
+ };
37229
+ }
37230
+ return { valid: true };
37231
+ }
37232
+ /**
37233
+ * Convert a Blob/File to a data URL
37234
+ */
37235
+ async toDataUrl(file) {
37236
+ return new Promise((resolve, reject) => {
37237
+ const reader = new FileReader();
37238
+ reader.onload = () => resolve(reader.result);
37239
+ reader.onerror = reject;
37240
+ reader.readAsDataURL(file);
37241
+ });
37242
+ }
37243
+ /**
37244
+ * Convert a data URL to a Blob
37245
+ */
37246
+ dataUrlToBlob(dataUrl) {
37247
+ const arr = dataUrl.split(',');
37248
+ const mime = arr[0].match(/:(.*?);/)[1];
37249
+ const bstr = atob(arr[1]);
37250
+ let n = bstr.length;
37251
+ const u8arr = new Uint8Array(n);
37252
+ while (n--) {
37253
+ u8arr[n] = bstr.charCodeAt(n);
37254
+ }
37255
+ return new Blob([u8arr], { type: mime });
37256
+ }
37257
+ // ============== Private Helpers ==============
37258
+ loadImage(file) {
37259
+ return new Promise((resolve, reject) => {
37260
+ const img = new Image();
37261
+ img.onload = () => {
37262
+ URL.revokeObjectURL(img.src);
37263
+ resolve(img);
37264
+ };
37265
+ img.onerror = reject;
37266
+ img.src = URL.createObjectURL(file);
37267
+ });
37268
+ }
37269
+ calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
37270
+ let width = originalWidth;
37271
+ let height = originalHeight;
37272
+ // Scale down if necessary, maintaining aspect ratio
37273
+ if (width > maxWidth) {
37274
+ height = (height * maxWidth) / width;
37275
+ width = maxWidth;
37276
+ }
37277
+ if (height > maxHeight) {
37278
+ width = (width * maxHeight) / height;
37279
+ height = maxHeight;
37280
+ }
37281
+ return {
37282
+ width: Math.round(width),
37283
+ height: Math.round(height),
37284
+ };
37285
+ }
37286
+ canvasToBlob(canvas, mimeType, quality) {
37287
+ return new Promise((resolve, reject) => {
37288
+ canvas.toBlob((blob) => {
37289
+ if (blob)
37290
+ resolve(blob);
37291
+ else
37292
+ reject(new Error('Failed to create blob from canvas'));
37293
+ }, mimeType, quality);
37294
+ });
37295
+ }
37296
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
37297
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, providedIn: 'root' }); }
37298
+ }
37299
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, decorators: [{
37300
+ type: Injectable,
37301
+ args: [{ providedIn: 'root' }]
37302
+ }] });
37303
+
37050
37304
  class AttachmentUploaderComponent {
37051
37305
  get readyUrls() {
37052
37306
  return this.attachments()
@@ -37069,6 +37323,7 @@ class AttachmentUploaderComponent {
37069
37323
  this.attachmentsChange = output();
37070
37324
  this.i18n = inject(I18nService);
37071
37325
  this.feedbackService = inject(FeedbackService, { optional: true });
37326
+ this.imageService = inject(ImageService);
37072
37327
  this.attachments = signal([]);
37073
37328
  this.showCameraOverlay = signal(false);
37074
37329
  this.cameraStream = null;
@@ -37170,6 +37425,35 @@ class AttachmentUploaderComponent {
37170
37425
  });
37171
37426
  });
37172
37427
  }
37428
+ /**
37429
+ * Genera y sube la miniatura de una imagen ya comprimida.
37430
+ *
37431
+ * Recibe el archivo COMPRIMIDO a propósito: `compressorjs` ya normalizó la
37432
+ * orientación EXIF, y `ImageService` trabaja sobre canvas sin leer EXIF — si
37433
+ * se le pasara el original, las fotos verticales de teléfono saldrían
37434
+ * rotadas. Ver la nota en `generateThumbnail` (types.ts).
37435
+ *
37436
+ * Nunca es fatal: si falla, el adjunto queda subido sin miniatura y las
37437
+ * listas caen al archivo grande. Perder la miniatura no justifica perder la
37438
+ * foto que el usuario acaba de subir.
37439
+ */
37440
+ async uploadThumbnail(compressed) {
37441
+ try {
37442
+ const size = this.props().thumbnailSize ?? 320;
37443
+ const thumb = await this.imageService.thumbnail(compressed, size);
37444
+ const baseName = compressed.name.replace(/\.[^.]+$/, '');
37445
+ const thumbFile = new File([thumb.blob], `${baseName}-thumb.jpg`, { type: 'image/jpeg' });
37446
+ const thumbUploadFn = this.props().thumbnailUploadFn ??
37447
+ this.props().uploadFn ??
37448
+ this.feedbackService?.uploadAttachment.bind(this.feedbackService);
37449
+ if (!thumbUploadFn)
37450
+ return undefined;
37451
+ return await thumbUploadFn(thumbFile);
37452
+ }
37453
+ catch {
37454
+ return undefined;
37455
+ }
37456
+ }
37173
37457
  async uploadFile(id, file) {
37174
37458
  try {
37175
37459
  const shouldCompress = this.props().compressImages !== false && file.type.startsWith('image/');
@@ -37178,7 +37462,15 @@ class AttachmentUploaderComponent {
37178
37462
  if (!uploadFn)
37179
37463
  throw new Error('No upload function configured');
37180
37464
  const url = await uploadFn(fileToUpload);
37181
- this.attachments.update(list => list.map(a => (a.id === id ? { ...a, status: 'ready', url } : a)));
37465
+ // La miniatura SIEMPRE sale de un archivo pasado por compressorjs, que es
37466
+ // lo que endereza la orientación EXIF. Si el consumer desactivó la
37467
+ // compresión, se comprime igual solo para generar la miniatura — el
37468
+ // archivo que se sube como principal sigue siendo el original.
37469
+ const wantsThumb = this.props().generateThumbnail === true && file.type.startsWith('image/');
37470
+ const thumbnailUrl = wantsThumb
37471
+ ? await this.uploadThumbnail(shouldCompress ? fileToUpload : await this.compressImage(file))
37472
+ : undefined;
37473
+ this.attachments.update(list => list.map(a => (a.id === id ? { ...a, status: 'ready', url, thumbnailUrl } : a)));
37182
37474
  }
37183
37475
  catch {
37184
37476
  const error = this.i18n.t('attachUploadFailed');
@@ -38687,6 +38979,8 @@ const SURVEY_RESPONSE_I18N = {
38687
38979
  invalidEmail: 'Revisa el correo, parece incompleto',
38688
38980
  required: 'Esta pregunta es obligatoria',
38689
38981
  submit: 'Enviar',
38982
+ alreadyTitle: 'Ya respondiste esta encuesta',
38983
+ alreadyBody: 'Tu respuesta quedó registrada la primera vez. Gracias por tu tiempo.',
38690
38984
  sentTitle: 'Gracias por responder',
38691
38985
  sentBody: 'Tu respuesta quedó registrada.',
38692
38986
  loadErrorTitle: 'No pudimos cargar la encuesta',
@@ -38705,6 +38999,8 @@ const SURVEY_RESPONSE_I18N = {
38705
38999
  invalidEmail: 'Check the email, it looks incomplete',
38706
39000
  required: 'This question is required',
38707
39001
  submit: 'Submit',
39002
+ alreadyTitle: 'You already answered this survey',
39003
+ alreadyBody: 'Your response was recorded the first time. Thanks for your time.',
38708
39004
  sentTitle: 'Thanks for answering',
38709
39005
  sentBody: 'Your response was recorded.',
38710
39006
  loadErrorTitle: "We couldn't load the survey",
@@ -38715,7 +39011,9 @@ const SURVEY_RESPONSE_I18N = {
38715
39011
  },
38716
39012
  };
38717
39013
 
38718
- const NAMESPACE$3 = 'SurveyResponse';
39014
+ const NAMESPACE$4 = 'SurveyResponse';
39015
+ /** Code del backend cuando la invitación de esa persona ya se respondió. */
39016
+ const INVITE_USED_CODE = 'SURVEY_INVITE_ALREADY_USED';
38719
39017
  /** Tipos cuya respuesta es un booleano, no un texto. */
38720
39018
  const BOOLEAN_TYPES = ['CHECK', 'TOGGLE'];
38721
39019
  /**
@@ -38754,6 +39052,7 @@ class SurveyResponseComponent {
38754
39052
  this.sending = signal(false);
38755
39053
  this.typeConfig = signal(null);
38756
39054
  this.multiSelectError = signal(false);
39055
+ this.alreadyAnswered = signal(false);
38757
39056
  this.nameControl = new FormControl('', { nonNullable: true });
38758
39057
  this.emailControl = new FormControl('', { nonNullable: true });
38759
39058
  this.form = new FormGroup({});
@@ -38775,6 +39074,10 @@ class SurveyResponseComponent {
38775
39074
  return !!this.typeConfig()?.allowAnonymous;
38776
39075
  });
38777
39076
  this.submitState = computed(() => (this.sending() ? 'WORKING' : 'ENABLED'));
39077
+ this.alreadyAnsweredState = computed(() => {
39078
+ this.i18n.lang();
39079
+ return { variant: 'empty', title: this.t('alreadyTitle'), description: this.t('alreadyBody') };
39080
+ });
38778
39081
  this.sentState = computed(() => {
38779
39082
  this.i18n.lang();
38780
39083
  return { variant: 'empty', title: this.t('sentTitle'), description: this.t('sentBody') };
@@ -38787,8 +39090,8 @@ class SurveyResponseComponent {
38787
39090
  this.i18n.lang();
38788
39091
  return { variant: 'error', title: this.t('loginRequiredTitle'), description: this.t('loginRequiredBody') };
38789
39092
  });
38790
- if (!this.i18n.hasNamespace(NAMESPACE$3)) {
38791
- this.i18n.registerDefaults(NAMESPACE$3, SURVEY_RESPONSE_I18N);
39093
+ if (!this.i18n.hasNamespace(NAMESPACE$4)) {
39094
+ this.i18n.registerDefaults(NAMESPACE$4, SURVEY_RESPONSE_I18N);
38792
39095
  }
38793
39096
  }
38794
39097
  async ngOnInit() {
@@ -38952,6 +39255,7 @@ class SurveyResponseComponent {
38952
39255
  type: cfg.typeId,
38953
39256
  title: cfg.label,
38954
39257
  fields,
39258
+ ...(this.props.inviteToken ? { inviteToken: this.props.inviteToken } : {}),
38955
39259
  ...(this.needsIdentity()
38956
39260
  ? { submitter: { name: this.nameControl.value.trim(), email: this.emailControl.value.trim() } }
38957
39261
  : {}),
@@ -38967,15 +39271,22 @@ class SurveyResponseComponent {
38967
39271
  }
38968
39272
  catch (err) {
38969
39273
  this.sending.set(false);
39274
+ // Una invitación ya usada no es un fallo: es la respuesta correcta al
39275
+ // segundo intento. Un toast rojo de "no pudimos enviar" haría creer que
39276
+ // se perdió lo que ya se había respondido.
39277
+ if (interpretError(err).code === INVITE_USED_CODE) {
39278
+ this.alreadyAnswered.set(true);
39279
+ return;
39280
+ }
38970
39281
  this.errors.handle(err, {
38971
39282
  context: 'surveyResponse.submit',
38972
39283
  fallbackKey: 'sendError',
38973
- i18nNamespace: NAMESPACE$3,
39284
+ i18nNamespace: NAMESPACE$4,
38974
39285
  });
38975
39286
  }
38976
39287
  }
38977
39288
  t(key) {
38978
- return this.i18n.t(key, NAMESPACE$3);
39289
+ return this.i18n.t(key, NAMESPACE$4);
38979
39290
  }
38980
39291
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SurveyResponseComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
38981
39292
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SurveyResponseComponent, isStandalone: true, selector: "val-survey-response", inputs: { props: "props" }, outputs: { submitted: "submitted" }, ngImport: i0, template: `
@@ -38983,6 +39294,8 @@ class SurveyResponseComponent {
38983
39294
  <div class="survey-response__loading" aria-hidden="true"></div>
38984
39295
  } @else if (loadError()) {
38985
39296
  <val-empty-state [props]="loadErrorState()" />
39297
+ } @else if (alreadyAnswered()) {
39298
+ <val-empty-state [props]="alreadyAnsweredState()" />
38986
39299
  } @else if (sent()) {
38987
39300
  <val-empty-state [props]="sentState()" />
38988
39301
  } @else {
@@ -39150,6 +39463,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
39150
39463
  <div class="survey-response__loading" aria-hidden="true"></div>
39151
39464
  } @else if (loadError()) {
39152
39465
  <val-empty-state [props]="loadErrorState()" />
39466
+ } @else if (alreadyAnswered()) {
39467
+ <val-empty-state [props]="alreadyAnsweredState()" />
39153
39468
  } @else if (sent()) {
39154
39469
  <val-empty-state [props]="sentState()" />
39155
39470
  } @else {
@@ -39580,7 +39895,7 @@ const FIELD_SCHEMA_EDITOR_I18N = {
39580
39895
  },
39581
39896
  };
39582
39897
 
39583
- const NAMESPACE$2 = 'FieldSchemaEditor';
39898
+ const NAMESPACE$3 = 'FieldSchemaEditor';
39584
39899
  /**
39585
39900
  * Tipos cuyo valor sale de una lista cerrada que define quien arma el
39586
39901
  * formulario — sin al menos una opción, el campo no se puede responder.
@@ -39665,8 +39980,8 @@ class FieldSchemaEditorComponent {
39665
39980
  options: this.options(),
39666
39981
  };
39667
39982
  });
39668
- if (!this.i18n.hasNamespace(NAMESPACE$2)) {
39669
- this.i18n.registerDefaults(NAMESPACE$2, FIELD_SCHEMA_EDITOR_I18N);
39983
+ if (!this.i18n.hasNamespace(NAMESPACE$3)) {
39984
+ this.i18n.registerDefaults(NAMESPACE$3, FIELD_SCHEMA_EDITOR_I18N);
39670
39985
  }
39671
39986
  this.typeControl.valueChanges.subscribe(value => {
39672
39987
  this.currentType.set(value || 'TEXT');
@@ -39703,7 +40018,7 @@ class FieldSchemaEditorComponent {
39703
40018
  }
39704
40019
  t(key) {
39705
40020
  this.i18n.lang();
39706
- return this.i18n.t(key, NAMESPACE$2);
40021
+ return this.i18n.t(key, NAMESPACE$3);
39707
40022
  }
39708
40023
  state() {
39709
40024
  return this.props.state ?? ComponentStates.ENABLED;
@@ -39930,6 +40245,379 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
39930
40245
  type: Output
39931
40246
  }] } });
39932
40247
 
40248
+ const REQUEST_REVIEW_PANEL_I18N = {
40249
+ es: {
40250
+ approve: 'Aprobar',
40251
+ reject: 'Rechazar',
40252
+ cancel: 'Cancelar',
40253
+ confirmReject: 'Confirmar rechazo',
40254
+ rejectReason: 'Motivo del rechazo',
40255
+ rejectReasonPlaceholder: 'Cuéntale qué le faltó o qué debe corregir',
40256
+ rejectReasonHint: 'Esto le llega a quien envió la solicitud.',
40257
+ },
40258
+ en: {
40259
+ approve: 'Approve',
40260
+ reject: 'Reject',
40261
+ cancel: 'Cancel',
40262
+ confirmReject: 'Confirm rejection',
40263
+ rejectReason: 'Reason for rejection',
40264
+ rejectReasonPlaceholder: 'Tell them what was missing or needs fixing',
40265
+ rejectReasonHint: 'This is sent to whoever submitted the request.',
40266
+ },
40267
+ };
40268
+
40269
+ const NAMESPACE$2 = 'RequestReviewPanel';
40270
+ /**
40271
+ * val-request-review-panel — cola de revisión del factory (ADR-062).
40272
+ *
40273
+ * Es **presentacional**: no carga datos, no conoce el backend ni el dominio. El
40274
+ * consumer le pasa la cola ya normalizada y escucha `(decision)`.
40275
+ *
40276
+ * ## Por qué el cuerpo va proyectado y no configurado
40277
+ *
40278
+ * El ADR original proponía describir el cuerpo con arrays de nombres de campo
40279
+ * (`fields.display: ['taxId', 'tagsRequested']`). Al implementarlo con los dos
40280
+ * consumidores reales delante quedó claro que eso reinventa un motor de
40281
+ * templating peor que Angular: Chesed necesita una lista de documentos con
40282
+ * iconos y `target="_blank"`, Okhelia un enlace a la receta y un input de
40283
+ * comentario. Ninguna lista de strings expresa eso sin agregarle un flag al
40284
+ * componente por cada caso nuevo.
40285
+ *
40286
+ * Entonces el panel es dueño de lo que **de verdad** se repite — la cáscara, el
40287
+ * chrome de la card, y la mecánica de decisión — y el cuerpo de cada ítem lo
40288
+ * escribe cada vertical en un `ng-template` con su propio HTML.
40289
+ *
40290
+ * ```html
40291
+ * <val-request-review-panel [props]="panelProps()" [itemBody]="body"
40292
+ * (decision)="onDecision($event)" />
40293
+ *
40294
+ * <ng-template #body let-item>
40295
+ * <a [routerLink]="['/app/recetas', recipeIdOf(item)]">Ver receta</a>
40296
+ * </ng-template>
40297
+ * ```
40298
+ *
40299
+ * ## Rechazo con motivo
40300
+ *
40301
+ * El flujo de dos pasos (Rechazar → escribir motivo → Confirmar) es del panel,
40302
+ * no del consumer, y está activo por defecto. Rechazar de un click deja al
40303
+ * solicitante sin saber qué corregir; era el único de los dos paneles que lo
40304
+ * tenía bien resuelto y ahora lo heredan los dos.
40305
+ */
40306
+ class RequestReviewPanelComponent {
40307
+ constructor() {
40308
+ this.i18n = inject(I18nService);
40309
+ this.props = input.required();
40310
+ this.decision = new EventEmitter();
40311
+ /** ID del ítem cuyo formulario de rechazo está abierto. */
40312
+ this._rejectingId = signal('');
40313
+ this.rejectingId = this._rejectingId.asReadonly();
40314
+ this._reason = signal('');
40315
+ this.reason = this._reason.asReadonly();
40316
+ this.cfg = computed(() => this.props());
40317
+ this.skeletonRows = computed(() => Array.from({ length: this.cfg().skeletonRows ?? 3 }, (_, i) => i));
40318
+ /** Hay una decisión en vuelo: toda la cola se congela. */
40319
+ this.isBusy = computed(() => !!this.cfg().busyItemId);
40320
+ if (!this.i18n.hasNamespace(NAMESPACE$2)) {
40321
+ this.i18n.registerContent(NAMESPACE$2, REQUEST_REVIEW_PANEL_I18N);
40322
+ }
40323
+ }
40324
+ t(key) {
40325
+ return this.i18n.t(key, NAMESPACE$2);
40326
+ }
40327
+ /** El ítem en vuelo muestra WORKING; los demás quedan deshabilitados. */
40328
+ stateFor(item) {
40329
+ const busyId = this.cfg().busyItemId;
40330
+ if (busyId === item.id)
40331
+ return 'WORKING';
40332
+ return busyId ? 'DISABLED' : 'ENABLED';
40333
+ }
40334
+ approveState(item) {
40335
+ return this.stateFor(item);
40336
+ }
40337
+ /**
40338
+ * Confirmar el rechazo exige motivo cuando `requireRejectReason` está activo
40339
+ * (el default). Es el freno que evita la notificación de rechazo vacía.
40340
+ */
40341
+ confirmRejectState(item) {
40342
+ const state = this.stateFor(item);
40343
+ if (state !== 'ENABLED')
40344
+ return state;
40345
+ const needsReason = this.cfg().requireRejectReason !== false;
40346
+ return needsReason && this._reason().trim().length === 0 ? 'DISABLED' : 'ENABLED';
40347
+ }
40348
+ onReasonInput(event) {
40349
+ this._reason.set(event.target.value);
40350
+ }
40351
+ startReject(item) {
40352
+ // Sin motivo obligatorio el rechazo es directo: abrir un formulario que no
40353
+ // exige nada solo agrega un paso.
40354
+ if (this.cfg().requireRejectReason === false) {
40355
+ this.decision.emit({ itemId: item.id, approved: false });
40356
+ return;
40357
+ }
40358
+ this._reason.set('');
40359
+ this._rejectingId.set(item.id);
40360
+ }
40361
+ cancelReject() {
40362
+ this._rejectingId.set('');
40363
+ this._reason.set('');
40364
+ }
40365
+ confirmReject(item) {
40366
+ const reason = this._reason().trim();
40367
+ if (this.cfg().requireRejectReason !== false && reason.length === 0)
40368
+ return;
40369
+ this.decision.emit({ itemId: item.id, approved: false, reason: reason || undefined });
40370
+ this.cancelReject();
40371
+ }
40372
+ approve(item) {
40373
+ this.decision.emit({ itemId: item.id, approved: true });
40374
+ }
40375
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestReviewPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
40376
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: RequestReviewPanelComponent, isStandalone: true, selector: "val-request-review-panel", inputs: { props: { classPropertyName: "props", publicName: "props", isSignal: true, isRequired: true, transformFunction: null }, itemBody: { classPropertyName: "itemBody", publicName: "itemBody", isSignal: false, isRequired: false, transformFunction: null } }, outputs: { decision: "decision" }, ngImport: i0, template: `
40377
+ <div class="rrp">
40378
+ @if (cfg().loading) {
40379
+ <div class="rrp__skel" aria-hidden="true">
40380
+ @for (row of skeletonRows(); track row) {
40381
+ <val-skeleton [props]="{ width: '100%', height: '120px', borderRadius: '14px' }" />
40382
+ }
40383
+ </div>
40384
+ } @else if (cfg().errorState) {
40385
+ <val-empty-state [props]="cfg().errorState!" />
40386
+ } @else if (cfg().items.length === 0) {
40387
+ @if (cfg().emptyState; as empty) {
40388
+ <val-empty-state [props]="empty" />
40389
+ }
40390
+ } @else {
40391
+ @if (cfg().countLabel; as count) {
40392
+ <p class="rrp__count">{{ count }}</p>
40393
+ }
40394
+
40395
+ <ul class="rrp__queue">
40396
+ @for (item of cfg().items; track item.id) {
40397
+ <li class="rrp__item">
40398
+ <h3 class="rrp__title">{{ item.title }}</h3>
40399
+
40400
+ @for (line of item.meta ?? []; track line) {
40401
+ <p class="rrp__meta">{{ line }}</p>
40402
+ }
40403
+
40404
+ @if (itemBody) {
40405
+ <div class="rrp__body">
40406
+ <ng-container
40407
+ [ngTemplateOutlet]="itemBody"
40408
+ [ngTemplateOutletContext]="{ $implicit: item, item: item }"
40409
+ />
40410
+ </div>
40411
+ }
40412
+
40413
+ @if (rejectingId() === item.id) {
40414
+ <div class="rrp__reject">
40415
+ <label class="rrp__reject-label" [attr.for]="'rrp-note-' + item.id">
40416
+ {{ cfg().rejectReasonLabel ?? t('rejectReason') }}
40417
+ </label>
40418
+ <textarea
40419
+ class="rrp__reject-input"
40420
+ [id]="'rrp-note-' + item.id"
40421
+ rows="3"
40422
+ [placeholder]="cfg().rejectReasonPlaceholder ?? t('rejectReasonPlaceholder')"
40423
+ [value]="reason()"
40424
+ (input)="onReasonInput($event)"
40425
+ ></textarea>
40426
+ <p class="rrp__reject-hint">
40427
+ {{ cfg().rejectReasonHint ?? t('rejectReasonHint') }}
40428
+ </p>
40429
+
40430
+ <div class="rrp__actions">
40431
+ <val-button
40432
+ [props]="{
40433
+ token: 'rrp-cancel-' + item.id,
40434
+ text: cfg().cancelLabel ?? t('cancel'),
40435
+ color: 'dark',
40436
+ fill: 'clear',
40437
+ size: 'small',
40438
+ type: 'button',
40439
+ state: isBusy() ? 'DISABLED' : 'ENABLED',
40440
+ }"
40441
+ (onClick)="cancelReject()"
40442
+ />
40443
+ <val-button
40444
+ [props]="{
40445
+ token: 'rrp-confirm-reject-' + item.id,
40446
+ text: cfg().confirmRejectLabel ?? t('confirmReject'),
40447
+ color: 'danger',
40448
+ fill: 'solid',
40449
+ shape: 'round',
40450
+ size: 'small',
40451
+ type: 'button',
40452
+ state: confirmRejectState(item),
40453
+ }"
40454
+ (onClick)="confirmReject(item)"
40455
+ />
40456
+ </div>
40457
+ </div>
40458
+ } @else {
40459
+ <div class="rrp__actions">
40460
+ <val-button
40461
+ [props]="{
40462
+ token: 'rrp-reject-' + item.id,
40463
+ text: cfg().rejectLabel ?? t('reject'),
40464
+ color: 'dark',
40465
+ fill: 'outline',
40466
+ shape: 'round',
40467
+ size: 'small',
40468
+ type: 'button',
40469
+ state: isBusy() ? 'DISABLED' : 'ENABLED',
40470
+ }"
40471
+ (onClick)="startReject(item)"
40472
+ />
40473
+ <val-button
40474
+ [props]="{
40475
+ token: 'rrp-approve-' + item.id,
40476
+ text: cfg().approveLabel ?? t('approve'),
40477
+ color: 'primary',
40478
+ fill: 'solid',
40479
+ shape: 'round',
40480
+ size: 'small',
40481
+ type: 'button',
40482
+ state: approveState(item),
40483
+ }"
40484
+ (onClick)="approve(item)"
40485
+ />
40486
+ </div>
40487
+ }
40488
+ </li>
40489
+ }
40490
+ </ul>
40491
+ }
40492
+ </div>
40493
+ `, isInline: true, styles: [":host{display:block}.rrp{display:flex;flex-direction:column;gap:12px}.rrp__skel{display:flex;flex-direction:column;gap:10px}.rrp__count{margin:0;font-size:.8125rem;font-weight:700;color:var(--ion-color-medium, #92949c);font-variant-numeric:tabular-nums}.rrp__queue{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:12px}.rrp__item{display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:14px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1))}.rrp__title{margin:0;font-size:1rem;font-weight:700;color:var(--ion-text-color, #000)}.rrp__meta{margin:0;font-size:.8125rem;color:var(--ion-color-medium, #92949c)}.rrp__body{display:flex;flex-direction:column;gap:8px}.rrp__reject{display:flex;flex-direction:column;gap:6px}.rrp__reject-label{font-size:.8125rem;font-weight:600;color:var(--ion-text-color, #000)}.rrp__reject-input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-background-color, #fff);color:var(--ion-text-color, #000);font-family:inherit;font-size:.875rem;resize:vertical}.rrp__reject-input:focus{outline:none;border-color:var(--ion-color-primary)}.rrp__reject-hint{margin:0;font-size:.75rem;color:var(--ion-color-medium, #92949c)}.rrp__actions{display:flex;flex-direction:column;gap:8px;margin-top:4px}@media (min-width: 576px){.rrp__actions{flex-direction:row;justify-content:flex-end;align-items:center;flex-wrap:wrap}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$4.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
40494
+ }
40495
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestReviewPanelComponent, decorators: [{
40496
+ type: Component,
40497
+ args: [{ selector: 'val-request-review-panel', standalone: true, imports: [CommonModule, ButtonComponent, EmptyStateComponent, SkeletonComponent], template: `
40498
+ <div class="rrp">
40499
+ @if (cfg().loading) {
40500
+ <div class="rrp__skel" aria-hidden="true">
40501
+ @for (row of skeletonRows(); track row) {
40502
+ <val-skeleton [props]="{ width: '100%', height: '120px', borderRadius: '14px' }" />
40503
+ }
40504
+ </div>
40505
+ } @else if (cfg().errorState) {
40506
+ <val-empty-state [props]="cfg().errorState!" />
40507
+ } @else if (cfg().items.length === 0) {
40508
+ @if (cfg().emptyState; as empty) {
40509
+ <val-empty-state [props]="empty" />
40510
+ }
40511
+ } @else {
40512
+ @if (cfg().countLabel; as count) {
40513
+ <p class="rrp__count">{{ count }}</p>
40514
+ }
40515
+
40516
+ <ul class="rrp__queue">
40517
+ @for (item of cfg().items; track item.id) {
40518
+ <li class="rrp__item">
40519
+ <h3 class="rrp__title">{{ item.title }}</h3>
40520
+
40521
+ @for (line of item.meta ?? []; track line) {
40522
+ <p class="rrp__meta">{{ line }}</p>
40523
+ }
40524
+
40525
+ @if (itemBody) {
40526
+ <div class="rrp__body">
40527
+ <ng-container
40528
+ [ngTemplateOutlet]="itemBody"
40529
+ [ngTemplateOutletContext]="{ $implicit: item, item: item }"
40530
+ />
40531
+ </div>
40532
+ }
40533
+
40534
+ @if (rejectingId() === item.id) {
40535
+ <div class="rrp__reject">
40536
+ <label class="rrp__reject-label" [attr.for]="'rrp-note-' + item.id">
40537
+ {{ cfg().rejectReasonLabel ?? t('rejectReason') }}
40538
+ </label>
40539
+ <textarea
40540
+ class="rrp__reject-input"
40541
+ [id]="'rrp-note-' + item.id"
40542
+ rows="3"
40543
+ [placeholder]="cfg().rejectReasonPlaceholder ?? t('rejectReasonPlaceholder')"
40544
+ [value]="reason()"
40545
+ (input)="onReasonInput($event)"
40546
+ ></textarea>
40547
+ <p class="rrp__reject-hint">
40548
+ {{ cfg().rejectReasonHint ?? t('rejectReasonHint') }}
40549
+ </p>
40550
+
40551
+ <div class="rrp__actions">
40552
+ <val-button
40553
+ [props]="{
40554
+ token: 'rrp-cancel-' + item.id,
40555
+ text: cfg().cancelLabel ?? t('cancel'),
40556
+ color: 'dark',
40557
+ fill: 'clear',
40558
+ size: 'small',
40559
+ type: 'button',
40560
+ state: isBusy() ? 'DISABLED' : 'ENABLED',
40561
+ }"
40562
+ (onClick)="cancelReject()"
40563
+ />
40564
+ <val-button
40565
+ [props]="{
40566
+ token: 'rrp-confirm-reject-' + item.id,
40567
+ text: cfg().confirmRejectLabel ?? t('confirmReject'),
40568
+ color: 'danger',
40569
+ fill: 'solid',
40570
+ shape: 'round',
40571
+ size: 'small',
40572
+ type: 'button',
40573
+ state: confirmRejectState(item),
40574
+ }"
40575
+ (onClick)="confirmReject(item)"
40576
+ />
40577
+ </div>
40578
+ </div>
40579
+ } @else {
40580
+ <div class="rrp__actions">
40581
+ <val-button
40582
+ [props]="{
40583
+ token: 'rrp-reject-' + item.id,
40584
+ text: cfg().rejectLabel ?? t('reject'),
40585
+ color: 'dark',
40586
+ fill: 'outline',
40587
+ shape: 'round',
40588
+ size: 'small',
40589
+ type: 'button',
40590
+ state: isBusy() ? 'DISABLED' : 'ENABLED',
40591
+ }"
40592
+ (onClick)="startReject(item)"
40593
+ />
40594
+ <val-button
40595
+ [props]="{
40596
+ token: 'rrp-approve-' + item.id,
40597
+ text: cfg().approveLabel ?? t('approve'),
40598
+ color: 'primary',
40599
+ fill: 'solid',
40600
+ shape: 'round',
40601
+ size: 'small',
40602
+ type: 'button',
40603
+ state: approveState(item),
40604
+ }"
40605
+ (onClick)="approve(item)"
40606
+ />
40607
+ </div>
40608
+ }
40609
+ </li>
40610
+ }
40611
+ </ul>
40612
+ }
40613
+ </div>
40614
+ `, styles: [":host{display:block}.rrp{display:flex;flex-direction:column;gap:12px}.rrp__skel{display:flex;flex-direction:column;gap:10px}.rrp__count{margin:0;font-size:.8125rem;font-weight:700;color:var(--ion-color-medium, #92949c);font-variant-numeric:tabular-nums}.rrp__queue{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:12px}.rrp__item{display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:14px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1))}.rrp__title{margin:0;font-size:1rem;font-weight:700;color:var(--ion-text-color, #000)}.rrp__meta{margin:0;font-size:.8125rem;color:var(--ion-color-medium, #92949c)}.rrp__body{display:flex;flex-direction:column;gap:8px}.rrp__reject{display:flex;flex-direction:column;gap:6px}.rrp__reject-label{font-size:.8125rem;font-weight:600;color:var(--ion-text-color, #000)}.rrp__reject-input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-background-color, #fff);color:var(--ion-text-color, #000);font-family:inherit;font-size:.875rem;resize:vertical}.rrp__reject-input:focus{outline:none;border-color:var(--ion-color-primary)}.rrp__reject-hint{margin:0;font-size:.75rem;color:var(--ion-color-medium, #92949c)}.rrp__actions{display:flex;flex-direction:column;gap:8px;margin-top:4px}@media (min-width: 576px){.rrp__actions{flex-direction:row;justify-content:flex-end;align-items:center;flex-wrap:wrap}}\n"] }]
40615
+ }], ctorParameters: () => [], propDecorators: { itemBody: [{
40616
+ type: Input
40617
+ }], decision: [{
40618
+ type: Output
40619
+ }] } });
40620
+
39933
40621
  class RequestFormBuilderService extends FormSchemaBuilderService {
39934
40622
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestFormBuilderService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
39935
40623
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestFormBuilderService, providedIn: 'root' }); }
@@ -44976,521 +45664,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
44976
45664
  type: Output
44977
45665
  }] } });
44978
45666
 
44979
- const STATUS_STYLE = {
44980
- approved: { bg: '#22c55e', color: '#fff' },
44981
- rejected: { bg: '#ef4444', color: '#fff' },
44982
- in_review: { bg: '#fef3c7', color: '#92400e' },
44983
- pending: { bg: 'var(--ion-color-light, #f4f5f8)', color: 'var(--ion-color-dark, #222)' },
44984
- cancelled: { bg: 'var(--ion-color-light, #f4f5f8)', color: 'var(--ion-color-medium, #636469)' },
44985
- closed: { bg: 'var(--ion-color-light, #f4f5f8)', color: 'var(--ion-color-medium, #636469)' },
44986
- };
44987
- /**
44988
- * Panel de revisión de una Request genérica (ADR-061/062): status + acciones
44989
- * approve/reject + fields del formulario + comentarios. Config-driven, sin
44990
- * conocimiento del dominio (adoption, recipe_verification, etc.) — cada
44991
- * consumer inyecta su `RequestReviewPanelConfig`.
44992
- */
44993
- class RequestReviewPanelComponent {
44994
- constructor() {
44995
- this.requests = inject(RequestService);
44996
- this.i18n = inject(I18nService);
44997
- this.errors = inject(ValtechErrorService);
44998
- this.toast = inject(ToastService);
44999
- this.destroyRef = inject(DestroyRef);
45000
- this.requestId = input.required();
45001
- this.config = input.required();
45002
- this.loaded = output();
45003
- this._req = signal(null);
45004
- this._loading = signal(true);
45005
- this._loadError = signal(null);
45006
- this._comments = signal([]);
45007
- this._loadingComments = signal(false);
45008
- this._transitioning = signal(false);
45009
- this.req = this._req.asReadonly();
45010
- this.loading = this._loading.asReadonly();
45011
- this.loadError = this._loadError.asReadonly();
45012
- this.comments = this._comments.asReadonly();
45013
- this.loadingComments = this._loadingComments.asReadonly();
45014
- this.transitioning = this._transitioning.asReadonly();
45015
- this.heroImgFailed = signal(false);
45016
- this.heroConfig = computed(() => this.config().entityHero);
45017
- this.statusStyle = computed(() => STATUS_STYLE[this._req()?.status ?? ''] ?? STATUS_STYLE['pending']);
45018
- this.heroName = computed(() => {
45019
- const hero = this.heroConfig();
45020
- if (!hero)
45021
- return undefined;
45022
- return this._req()?.metadata?.[hero.nameField];
45023
- });
45024
- this.heroImage = computed(() => {
45025
- const hero = this.heroConfig();
45026
- if (!hero?.imageField)
45027
- return undefined;
45028
- return this._req()?.metadata?.[hero.imageField];
45029
- });
45030
- this.canNavigateHero = computed(() => {
45031
- const hero = this.heroConfig();
45032
- const metadata = this._req()?.metadata;
45033
- if (!hero || !metadata)
45034
- return false;
45035
- return hero.canNavigate(metadata);
45036
- });
45037
- this.submitterInitial = computed(() => {
45038
- const s = this._req()?.submitter;
45039
- if (!s)
45040
- return '?';
45041
- return ((s.name || s.email) ?? '?').charAt(0).toUpperCase();
45042
- });
45043
- this.fieldEntries = computed(() => {
45044
- const fields = this._req()?.fields ?? {};
45045
- return Object.entries(fields)
45046
- .filter(([, v]) => v != null && v !== '')
45047
- .map(([k, v]) => ({ key: k, value: String(v) }));
45048
- });
45049
- this.errorState = computed(() => {
45050
- this.i18n.lang();
45051
- const err = this._loadError();
45052
- const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase();
45053
- const isOffline = msg.includes('network') || msg.includes('offline');
45054
- return {
45055
- variant: 'error',
45056
- title: isOffline ? this.t('offlineTitle') : this.t('errorTitle'),
45057
- description: isOffline ? this.t('offlineHint') : '',
45058
- cta: { label: this.t('retry'), handler: () => void this.load() },
45059
- };
45060
- });
45061
- }
45062
- ngOnInit() {
45063
- void this.load();
45064
- }
45065
- async load() {
45066
- const id = this.requestId();
45067
- this._loading.set(true);
45068
- this._loadError.set(null);
45069
- try {
45070
- const req = await firstValueFrom(this.requests.getRequest(id));
45071
- this._req.set(req);
45072
- this.loaded.emit(req);
45073
- this.subscribeComments(req.appId, req.orgId, id);
45074
- }
45075
- catch (err) {
45076
- this._loadError.set(err);
45077
- this.errors.handle(err, {
45078
- context: 'request-review-panel.load',
45079
- fallbackKey: 'errorTitle',
45080
- i18nNamespace: this.config().i18nNamespace,
45081
- });
45082
- }
45083
- finally {
45084
- this._loading.set(false);
45085
- }
45086
- }
45087
- subscribeComments(appId, orgId, requestId) {
45088
- this._loadingComments.set(true);
45089
- this.requests
45090
- .watchComments(appId, orgId, requestId)
45091
- .pipe(takeUntilDestroyed(this.destroyRef))
45092
- .subscribe({
45093
- next: comments => {
45094
- this._comments.set(comments);
45095
- this._loadingComments.set(false);
45096
- },
45097
- error: () => {
45098
- // comentarios son best-effort; si el listener falla, silenciar y ocultar spinner
45099
- this._loadingComments.set(false);
45100
- },
45101
- });
45102
- }
45103
- async transition(status) {
45104
- const req = this._req();
45105
- if (!req || this._transitioning())
45106
- return;
45107
- this._transitioning.set(true);
45108
- try {
45109
- await firstValueFrom(this.requests.transition(req.id, { status }));
45110
- await this.load();
45111
- this.toast.show({ message: this.t('statusUpdated'), duration: 2500, color: 'dark', position: 'top' });
45112
- }
45113
- catch (err) {
45114
- this.errors.handle(err, {
45115
- context: 'request-review-panel.transition',
45116
- fallbackKey: 'statusError',
45117
- i18nNamespace: this.config().i18nNamespace,
45118
- });
45119
- }
45120
- finally {
45121
- this._transitioning.set(false);
45122
- }
45123
- }
45124
- onHeroClick() {
45125
- const hero = this.heroConfig();
45126
- const metadata = this._req()?.metadata;
45127
- if (hero && metadata)
45128
- hero.navigate(metadata);
45129
- }
45130
- formatDate(iso) {
45131
- if (!iso)
45132
- return '';
45133
- try {
45134
- return new Date(iso).toLocaleDateString(this.i18n.lang() === 'es' ? 'es-CL' : 'en-US', {
45135
- day: 'numeric',
45136
- month: 'short',
45137
- year: 'numeric',
45138
- });
45139
- }
45140
- catch {
45141
- return iso;
45142
- }
45143
- }
45144
- authorInitial(c) {
45145
- return ((c.author.name || c.author.email) ?? '?').charAt(0).toUpperCase();
45146
- }
45147
- relativeTime(iso) {
45148
- if (!iso)
45149
- return '';
45150
- const diffMs = Date.now() - new Date(iso).getTime();
45151
- if (Number.isNaN(diffMs))
45152
- return iso;
45153
- const lang = this.i18n.lang();
45154
- const mins = Math.floor(diffMs / 60000);
45155
- if (mins < 1)
45156
- return lang === 'es' ? 'hace un momento' : 'just now';
45157
- if (mins < 60)
45158
- return lang === 'es' ? `hace ${mins} min` : `${mins}m ago`;
45159
- const hours = Math.floor(mins / 60);
45160
- if (hours < 24)
45161
- return lang === 'es' ? `hace ${hours} h` : `${hours}h ago`;
45162
- const days = Math.floor(hours / 24);
45163
- if (days < 7)
45164
- return lang === 'es' ? `hace ${days} dia${days > 1 ? 's' : ''}` : `${days}d ago`;
45165
- return this.formatDate(iso);
45166
- }
45167
- t(key) {
45168
- return this.i18n.t(key, this.config().i18nNamespace);
45169
- }
45170
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestReviewPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
45171
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: RequestReviewPanelComponent, isStandalone: true, selector: "val-request-review-panel", inputs: { requestId: { classPropertyName: "requestId", publicName: "requestId", isSignal: true, isRequired: true, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { loaded: "loaded" }, ngImport: i0, template: `
45172
- <div class="page">
45173
- @if (loading()) {
45174
- <div class="skel" aria-hidden="true">
45175
- <val-skeleton [props]="{ width: '100%', height: '88px', borderRadius: '14px' }" />
45176
- <val-skeleton [props]="{ width: '100%', height: '64px', borderRadius: '12px' }" />
45177
- <val-skeleton [props]="{ width: '100%', height: '80px', borderRadius: '12px' }" />
45178
- <val-skeleton [props]="{ width: '100%', height: '120px', borderRadius: '12px' }" />
45179
- </div>
45180
- } @else if (loadError()) {
45181
- <val-empty-state [props]="errorState()" />
45182
- } @else if (req()) {
45183
- <div class="detail">
45184
- @if (heroConfig() && heroName()) {
45185
- <button class="entity-hero" (click)="onHeroClick()" [disabled]="!canNavigateHero()">
45186
- <div class="entity-hero__media">
45187
- @if (heroImage() && !heroImgFailed()) {
45188
- <img
45189
- [src]="heroImage()"
45190
- [alt]="heroName()!"
45191
- loading="lazy"
45192
- (error)="heroImgFailed.set(true)"
45193
- class="entity-hero__img"
45194
- />
45195
- } @else {
45196
- <span class="entity-hero__placeholder" aria-hidden="true">{{
45197
- heroConfig()!.placeholderIcon || '📄'
45198
- }}</span>
45199
- }
45200
- </div>
45201
- <div class="entity-hero__body">
45202
- <span class="entity-hero__label">{{ t(heroConfig()!.labelKey) }}</span>
45203
- <span class="entity-hero__name">{{ heroName() }}</span>
45204
- </div>
45205
- @if (canNavigateHero()) {
45206
- <span class="entity-hero__chevron" aria-hidden="true">›</span>
45207
- }
45208
- </button>
45209
- }
45210
-
45211
- <div class="status-card" [style.border-left-color]="statusStyle().bg">
45212
- <div class="status-card__top">
45213
- <span class="status-badge" [style.background]="statusStyle().bg" [style.color]="statusStyle().color">
45214
- {{ t('status_' + req()!.status) }}
45215
- </span>
45216
- <span class="submitted-at">{{ t('submittedAt') }}: {{ formatDate(req()!.createdAt) }}</span>
45217
- </div>
45218
-
45219
- @if (config().canReview() && (req()!.status === 'pending' || req()!.status === 'in_review')) {
45220
- <div class="actions-row">
45221
- <val-button
45222
- [props]="{
45223
- token: 'approve',
45224
- text: t('approve'),
45225
- color: 'primary',
45226
- fill: 'solid',
45227
- shape: 'round',
45228
- size: 'small',
45229
- type: 'button',
45230
- state: transitioning() ? 'WORKING' : 'ENABLED',
45231
- handler: transition.bind(this, 'approved'),
45232
- }"
45233
- />
45234
- <val-button
45235
- [props]="{
45236
- token: 'reject',
45237
- text: t('reject'),
45238
- color: 'medium',
45239
- fill: 'outline',
45240
- shape: 'round',
45241
- size: 'small',
45242
- type: 'button',
45243
- state: transitioning() ? 'WORKING' : 'ENABLED',
45244
- handler: transition.bind(this, 'rejected'),
45245
- }"
45246
- />
45247
- </div>
45248
- }
45249
- </div>
45250
-
45251
- @if (req()!.submitter) {
45252
- <div class="section">
45253
- <p class="section__title">{{ t('submitter') }}</p>
45254
- <div class="submitter-row">
45255
- <div class="submitter-avatar" aria-hidden="true">{{ submitterInitial() }}</div>
45256
- <div class="submitter-info">
45257
- @if (req()!.submitter!.name) {
45258
- <span class="submitter-name">{{ req()!.submitter!.name }}</span>
45259
- }
45260
- @if (req()!.submitter!.email) {
45261
- <span class="submitter-email">{{ req()!.submitter!.email }}</span>
45262
- }
45263
- @if (req()!.submitter!.phone) {
45264
- <span class="submitter-email">{{ req()!.submitter!.phone }}</span>
45265
- }
45266
- </div>
45267
- </div>
45268
- </div>
45269
- }
45270
-
45271
- @if (fieldEntries().length) {
45272
- <div class="section">
45273
- <p class="section__title">{{ t('fields') }}</p>
45274
- @for (entry of fieldEntries(); track entry.key) {
45275
- <div class="field-row">
45276
- <span class="field-row__label">{{ entry.key }}</span>
45277
- <span class="field-row__value">{{ entry.value }}</span>
45278
- </div>
45279
- }
45280
- </div>
45281
- }
45282
-
45283
- @if (
45284
- !config().canReview() && req()!.status === 'approved' && canNavigateHero() && config().viewEntityCtaLabelKey
45285
- ) {
45286
- <val-button
45287
- [props]="{
45288
- token: 'view-entity',
45289
- text: t(config().viewEntityCtaLabelKey!),
45290
- color: 'primary',
45291
- fill: 'solid',
45292
- shape: 'round',
45293
- size: 'default',
45294
- type: 'button',
45295
- state: 'ENABLED',
45296
- handler: onHeroClick.bind(this),
45297
- }"
45298
- />
45299
- }
45300
-
45301
- <div class="section">
45302
- <p class="section__title">{{ t('commentsTitle') }}</p>
45303
- @if (loadingComments()) {
45304
- <div class="section-pad">
45305
- <val-skeleton [props]="{ width: '100%', height: '48px', borderRadius: '8px' }" />
45306
- </div>
45307
- } @else if (comments().length === 0) {
45308
- <p class="no-comments">{{ t('noComments') }}</p>
45309
- } @else {
45310
- <ul class="comments">
45311
- @for (c of comments(); track c.commentId) {
45312
- <li class="comment">
45313
- <div class="comment__avatar" aria-hidden="true">{{ authorInitial(c) }}</div>
45314
- <div class="comment__content">
45315
- <div class="comment__header">
45316
- <span class="comment__author">{{ c.author.name || c.author.email }}</span>
45317
- <span class="comment__date">{{ relativeTime(c.createdAt) }}</span>
45318
- </div>
45319
- <p class="comment__body">{{ c.body }}</p>
45320
- </div>
45321
- </li>
45322
- }
45323
- </ul>
45324
- }
45325
- </div>
45326
- </div>
45327
- }
45328
- </div>
45329
- `, isInline: true, styles: [".page{display:flex;flex-direction:column;gap:14px}.skel{display:flex;flex-direction:column;gap:12px}.skel val-skeleton{display:block}.detail{display:flex;flex-direction:column;gap:14px}.entity-hero{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--ion-card-background, var(--ion-background-color, #fff));border-radius:14px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));width:100%;text-align:left;cursor:pointer}.entity-hero:disabled{cursor:default}.entity-hero__media{width:56px;height:56px;border-radius:12px;overflow:hidden;flex-shrink:0;background:var(--ion-color-light, #f4f5f8);display:flex;align-items:center;justify-content:center}.entity-hero__img{width:100%;height:100%;object-fit:cover}.entity-hero__placeholder{font-size:1.625rem}.entity-hero__body{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}.entity-hero__label{font-size:.75rem;color:var(--ion-color-dark, #636469)}.entity-hero__name{font-size:1rem;font-weight:600;color:var(--ion-text-color, #000);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.entity-hero__chevron{font-size:1.5rem;color:var(--ion-color-dark, #92949c);line-height:1}.status-card{background:var(--ion-card-background, var(--ion-background-color, #fff));border-radius:12px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));border-left:4px solid;padding:14px 16px;display:flex;flex-direction:column;gap:12px}.status-card__top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.status-badge{display:inline-block;padding:4px 12px;border-radius:100px;font-size:.8125rem;font-weight:700}.submitted-at{font-size:.8125rem;color:var(--ion-color-dark-shade, #1e2023)}.actions-row{display:flex;gap:10px;flex-wrap:wrap}.section{background:var(--ion-card-background, var(--ion-background-color, #fff));border-radius:12px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));overflow:hidden}.section__title{margin:0;font-size:.8125rem;font-weight:600;color:var(--ion-color-dark-shade, #1e2023);padding:12px 16px 0}.section-pad{padding:12px 16px}.submitter-row{display:flex;align-items:center;gap:12px;padding:12px 16px}.submitter-avatar{width:40px;height:40px;border-radius:50%;background:var(--ion-color-primary, #313131);color:var(--ion-color-primary-contrast, #fff);display:flex;align-items:center;justify-content:center;font-size:1rem;font-weight:700;flex-shrink:0}.submitter-info{display:flex;flex-direction:column;gap:2px;min-width:0}.submitter-name{font-size:.9375rem;font-weight:600;color:var(--ion-text-color, #000)}.submitter-email{font-size:.8125rem;color:var(--ion-color-dark, #636469);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.field-row{display:flex;flex-direction:column;gap:3px;padding:11px 16px;border-bottom:1px solid var(--ion-border-color, rgba(0, 0, 0, .06))}.field-row:last-child{border-bottom:none}.field-row__label{font-size:.75rem;color:var(--ion-color-dark, #636469);text-transform:capitalize}.field-row__value{font-size:.9375rem;color:var(--ion-text-color, #000);font-weight:500;word-break:break-word;white-space:pre-line}.no-comments{margin:0;padding:12px 16px;font-size:.875rem;color:var(--ion-color-dark, #92949c)}.comments{list-style:none;margin:0;padding:0}.comment{display:flex;align-items:flex-start;gap:10px;padding:12px 16px;border-bottom:1px solid var(--ion-border-color, rgba(0, 0, 0, .06))}.comment:last-child{border-bottom:none}.comment__avatar{width:32px;height:32px;border-radius:50%;background:var(--ion-color-primary, #313131);color:var(--ion-color-primary-contrast, #fff);display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:700;flex-shrink:0}.comment__content{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.comment__header{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.comment__author{font-size:.875rem;font-weight:600;color:var(--ion-text-color, #000)}.comment__date{font-size:.75rem;color:var(--ion-color-dark, #92949c);white-space:nowrap}.comment__body{margin:0;font-size:.875rem;color:var(--ion-text-color, #000);line-height:1.5;white-space:pre-line}\n"], dependencies: [{ kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "component", type: SkeletonComponent, selector: "val-skeleton", inputs: ["props"] }] }); }
45330
- }
45331
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestReviewPanelComponent, decorators: [{
45332
- type: Component,
45333
- args: [{ selector: 'val-request-review-panel', standalone: true, imports: [ButtonComponent, EmptyStateComponent, SkeletonComponent], template: `
45334
- <div class="page">
45335
- @if (loading()) {
45336
- <div class="skel" aria-hidden="true">
45337
- <val-skeleton [props]="{ width: '100%', height: '88px', borderRadius: '14px' }" />
45338
- <val-skeleton [props]="{ width: '100%', height: '64px', borderRadius: '12px' }" />
45339
- <val-skeleton [props]="{ width: '100%', height: '80px', borderRadius: '12px' }" />
45340
- <val-skeleton [props]="{ width: '100%', height: '120px', borderRadius: '12px' }" />
45341
- </div>
45342
- } @else if (loadError()) {
45343
- <val-empty-state [props]="errorState()" />
45344
- } @else if (req()) {
45345
- <div class="detail">
45346
- @if (heroConfig() && heroName()) {
45347
- <button class="entity-hero" (click)="onHeroClick()" [disabled]="!canNavigateHero()">
45348
- <div class="entity-hero__media">
45349
- @if (heroImage() && !heroImgFailed()) {
45350
- <img
45351
- [src]="heroImage()"
45352
- [alt]="heroName()!"
45353
- loading="lazy"
45354
- (error)="heroImgFailed.set(true)"
45355
- class="entity-hero__img"
45356
- />
45357
- } @else {
45358
- <span class="entity-hero__placeholder" aria-hidden="true">{{
45359
- heroConfig()!.placeholderIcon || '📄'
45360
- }}</span>
45361
- }
45362
- </div>
45363
- <div class="entity-hero__body">
45364
- <span class="entity-hero__label">{{ t(heroConfig()!.labelKey) }}</span>
45365
- <span class="entity-hero__name">{{ heroName() }}</span>
45366
- </div>
45367
- @if (canNavigateHero()) {
45368
- <span class="entity-hero__chevron" aria-hidden="true">›</span>
45369
- }
45370
- </button>
45371
- }
45372
-
45373
- <div class="status-card" [style.border-left-color]="statusStyle().bg">
45374
- <div class="status-card__top">
45375
- <span class="status-badge" [style.background]="statusStyle().bg" [style.color]="statusStyle().color">
45376
- {{ t('status_' + req()!.status) }}
45377
- </span>
45378
- <span class="submitted-at">{{ t('submittedAt') }}: {{ formatDate(req()!.createdAt) }}</span>
45379
- </div>
45380
-
45381
- @if (config().canReview() && (req()!.status === 'pending' || req()!.status === 'in_review')) {
45382
- <div class="actions-row">
45383
- <val-button
45384
- [props]="{
45385
- token: 'approve',
45386
- text: t('approve'),
45387
- color: 'primary',
45388
- fill: 'solid',
45389
- shape: 'round',
45390
- size: 'small',
45391
- type: 'button',
45392
- state: transitioning() ? 'WORKING' : 'ENABLED',
45393
- handler: transition.bind(this, 'approved'),
45394
- }"
45395
- />
45396
- <val-button
45397
- [props]="{
45398
- token: 'reject',
45399
- text: t('reject'),
45400
- color: 'medium',
45401
- fill: 'outline',
45402
- shape: 'round',
45403
- size: 'small',
45404
- type: 'button',
45405
- state: transitioning() ? 'WORKING' : 'ENABLED',
45406
- handler: transition.bind(this, 'rejected'),
45407
- }"
45408
- />
45409
- </div>
45410
- }
45411
- </div>
45412
-
45413
- @if (req()!.submitter) {
45414
- <div class="section">
45415
- <p class="section__title">{{ t('submitter') }}</p>
45416
- <div class="submitter-row">
45417
- <div class="submitter-avatar" aria-hidden="true">{{ submitterInitial() }}</div>
45418
- <div class="submitter-info">
45419
- @if (req()!.submitter!.name) {
45420
- <span class="submitter-name">{{ req()!.submitter!.name }}</span>
45421
- }
45422
- @if (req()!.submitter!.email) {
45423
- <span class="submitter-email">{{ req()!.submitter!.email }}</span>
45424
- }
45425
- @if (req()!.submitter!.phone) {
45426
- <span class="submitter-email">{{ req()!.submitter!.phone }}</span>
45427
- }
45428
- </div>
45429
- </div>
45430
- </div>
45431
- }
45432
-
45433
- @if (fieldEntries().length) {
45434
- <div class="section">
45435
- <p class="section__title">{{ t('fields') }}</p>
45436
- @for (entry of fieldEntries(); track entry.key) {
45437
- <div class="field-row">
45438
- <span class="field-row__label">{{ entry.key }}</span>
45439
- <span class="field-row__value">{{ entry.value }}</span>
45440
- </div>
45441
- }
45442
- </div>
45443
- }
45444
-
45445
- @if (
45446
- !config().canReview() && req()!.status === 'approved' && canNavigateHero() && config().viewEntityCtaLabelKey
45447
- ) {
45448
- <val-button
45449
- [props]="{
45450
- token: 'view-entity',
45451
- text: t(config().viewEntityCtaLabelKey!),
45452
- color: 'primary',
45453
- fill: 'solid',
45454
- shape: 'round',
45455
- size: 'default',
45456
- type: 'button',
45457
- state: 'ENABLED',
45458
- handler: onHeroClick.bind(this),
45459
- }"
45460
- />
45461
- }
45462
-
45463
- <div class="section">
45464
- <p class="section__title">{{ t('commentsTitle') }}</p>
45465
- @if (loadingComments()) {
45466
- <div class="section-pad">
45467
- <val-skeleton [props]="{ width: '100%', height: '48px', borderRadius: '8px' }" />
45468
- </div>
45469
- } @else if (comments().length === 0) {
45470
- <p class="no-comments">{{ t('noComments') }}</p>
45471
- } @else {
45472
- <ul class="comments">
45473
- @for (c of comments(); track c.commentId) {
45474
- <li class="comment">
45475
- <div class="comment__avatar" aria-hidden="true">{{ authorInitial(c) }}</div>
45476
- <div class="comment__content">
45477
- <div class="comment__header">
45478
- <span class="comment__author">{{ c.author.name || c.author.email }}</span>
45479
- <span class="comment__date">{{ relativeTime(c.createdAt) }}</span>
45480
- </div>
45481
- <p class="comment__body">{{ c.body }}</p>
45482
- </div>
45483
- </li>
45484
- }
45485
- </ul>
45486
- }
45487
- </div>
45488
- </div>
45489
- }
45490
- </div>
45491
- `, styles: [".page{display:flex;flex-direction:column;gap:14px}.skel{display:flex;flex-direction:column;gap:12px}.skel val-skeleton{display:block}.detail{display:flex;flex-direction:column;gap:14px}.entity-hero{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--ion-card-background, var(--ion-background-color, #fff));border-radius:14px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));width:100%;text-align:left;cursor:pointer}.entity-hero:disabled{cursor:default}.entity-hero__media{width:56px;height:56px;border-radius:12px;overflow:hidden;flex-shrink:0;background:var(--ion-color-light, #f4f5f8);display:flex;align-items:center;justify-content:center}.entity-hero__img{width:100%;height:100%;object-fit:cover}.entity-hero__placeholder{font-size:1.625rem}.entity-hero__body{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}.entity-hero__label{font-size:.75rem;color:var(--ion-color-dark, #636469)}.entity-hero__name{font-size:1rem;font-weight:600;color:var(--ion-text-color, #000);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.entity-hero__chevron{font-size:1.5rem;color:var(--ion-color-dark, #92949c);line-height:1}.status-card{background:var(--ion-card-background, var(--ion-background-color, #fff));border-radius:12px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));border-left:4px solid;padding:14px 16px;display:flex;flex-direction:column;gap:12px}.status-card__top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.status-badge{display:inline-block;padding:4px 12px;border-radius:100px;font-size:.8125rem;font-weight:700}.submitted-at{font-size:.8125rem;color:var(--ion-color-dark-shade, #1e2023)}.actions-row{display:flex;gap:10px;flex-wrap:wrap}.section{background:var(--ion-card-background, var(--ion-background-color, #fff));border-radius:12px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));overflow:hidden}.section__title{margin:0;font-size:.8125rem;font-weight:600;color:var(--ion-color-dark-shade, #1e2023);padding:12px 16px 0}.section-pad{padding:12px 16px}.submitter-row{display:flex;align-items:center;gap:12px;padding:12px 16px}.submitter-avatar{width:40px;height:40px;border-radius:50%;background:var(--ion-color-primary, #313131);color:var(--ion-color-primary-contrast, #fff);display:flex;align-items:center;justify-content:center;font-size:1rem;font-weight:700;flex-shrink:0}.submitter-info{display:flex;flex-direction:column;gap:2px;min-width:0}.submitter-name{font-size:.9375rem;font-weight:600;color:var(--ion-text-color, #000)}.submitter-email{font-size:.8125rem;color:var(--ion-color-dark, #636469);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.field-row{display:flex;flex-direction:column;gap:3px;padding:11px 16px;border-bottom:1px solid var(--ion-border-color, rgba(0, 0, 0, .06))}.field-row:last-child{border-bottom:none}.field-row__label{font-size:.75rem;color:var(--ion-color-dark, #636469);text-transform:capitalize}.field-row__value{font-size:.9375rem;color:var(--ion-text-color, #000);font-weight:500;word-break:break-word;white-space:pre-line}.no-comments{margin:0;padding:12px 16px;font-size:.875rem;color:var(--ion-color-dark, #92949c)}.comments{list-style:none;margin:0;padding:0}.comment{display:flex;align-items:flex-start;gap:10px;padding:12px 16px;border-bottom:1px solid var(--ion-border-color, rgba(0, 0, 0, .06))}.comment:last-child{border-bottom:none}.comment__avatar{width:32px;height:32px;border-radius:50%;background:var(--ion-color-primary, #313131);color:var(--ion-color-primary-contrast, #fff);display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:700;flex-shrink:0}.comment__content{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.comment__header{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.comment__author{font-size:.875rem;font-weight:600;color:var(--ion-text-color, #000)}.comment__date{font-size:.75rem;color:var(--ion-color-dark, #92949c);white-space:nowrap}.comment__body{margin:0;font-size:.875rem;color:var(--ion-text-color, #000);line-height:1.5;white-space:pre-line}\n"] }]
45492
- }], ctorParameters: () => [] });
45493
-
45494
45667
  /**
45495
45668
  * `val-cookie-banner` — bottom/top fixed banner asking the user to choose
45496
45669
  * a cookie consent option. Presentational only: emits events on each
@@ -54812,260 +54985,6 @@ function buildSideNavItemsFromBottomNav(config, options = {}) {
54812
54985
  }));
54813
54986
  }
54814
54987
 
54815
- /**
54816
- * Default values for image processing
54817
- */
54818
- const IMAGE_DEFAULTS = {
54819
- maxWidth: 800,
54820
- maxHeight: 800,
54821
- quality: 0.8,
54822
- mimeType: 'image/jpeg',
54823
- maxSize: 10 * 1024 * 1024, // 10MB
54824
- allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
54825
- thumbnailSize: 150,
54826
- };
54827
-
54828
- /**
54829
- * ImageService
54830
- *
54831
- * Service for image processing including compression, thumbnails, cropping and validation.
54832
- * Uses HTML Canvas for all operations - no external dependencies.
54833
- *
54834
- * @example
54835
- * ```typescript
54836
- * const imageService = inject(ImageService);
54837
- *
54838
- * // Compress an image
54839
- * const compressed = await imageService.compress(file, { maxWidth: 800, quality: 0.8 });
54840
- *
54841
- * // Generate thumbnail
54842
- * const thumb = await imageService.thumbnail(file, 150);
54843
- *
54844
- * // Validate before processing
54845
- * const validation = imageService.validate(file, { maxSize: 5 * 1024 * 1024 });
54846
- * if (!validation.valid) {
54847
- * console.error(validation.message);
54848
- * }
54849
- * ```
54850
- */
54851
- class ImageService {
54852
- /**
54853
- * Compress an image maintaining aspect ratio
54854
- * @param file - File or Blob to compress
54855
- * @param options - Compression options
54856
- * @returns Promise with processed image data
54857
- */
54858
- async compress(file, options) {
54859
- const opts = {
54860
- maxWidth: options?.maxWidth ?? IMAGE_DEFAULTS.maxWidth,
54861
- maxHeight: options?.maxHeight ?? IMAGE_DEFAULTS.maxHeight,
54862
- quality: options?.quality ?? IMAGE_DEFAULTS.quality,
54863
- mimeType: options?.mimeType ?? IMAGE_DEFAULTS.mimeType,
54864
- };
54865
- const img = await this.loadImage(file);
54866
- const { width, height } = this.calculateDimensions(img.width, img.height, opts.maxWidth, opts.maxHeight);
54867
- const canvas = document.createElement('canvas');
54868
- canvas.width = width;
54869
- canvas.height = height;
54870
- const ctx = canvas.getContext('2d');
54871
- ctx.drawImage(img, 0, 0, width, height);
54872
- const blob = await this.canvasToBlob(canvas, opts.mimeType, opts.quality);
54873
- const dataUrl = canvas.toDataURL(opts.mimeType, opts.quality);
54874
- return {
54875
- blob,
54876
- dataUrl,
54877
- width,
54878
- height,
54879
- size: blob.size,
54880
- };
54881
- }
54882
- /**
54883
- * Generate a square thumbnail from an image
54884
- * @param file - File or Blob to process
54885
- * @param size - Thumbnail size in pixels (default: 150)
54886
- * @returns Promise with processed thumbnail
54887
- */
54888
- async thumbnail(file, size) {
54889
- const thumbSize = size ?? IMAGE_DEFAULTS.thumbnailSize;
54890
- const img = await this.loadImage(file);
54891
- // Calculate square crop from center
54892
- const minDim = Math.min(img.width, img.height);
54893
- const cropX = (img.width - minDim) / 2;
54894
- const cropY = (img.height - minDim) / 2;
54895
- const canvas = document.createElement('canvas');
54896
- canvas.width = thumbSize;
54897
- canvas.height = thumbSize;
54898
- const ctx = canvas.getContext('2d');
54899
- ctx.drawImage(img, cropX, cropY, minDim, minDim, 0, 0, thumbSize, thumbSize);
54900
- const blob = await this.canvasToBlob(canvas, IMAGE_DEFAULTS.mimeType, 0.7 // Lower quality for thumbnails
54901
- );
54902
- const dataUrl = canvas.toDataURL(IMAGE_DEFAULTS.mimeType, 0.7);
54903
- return {
54904
- blob,
54905
- dataUrl,
54906
- width: thumbSize,
54907
- height: thumbSize,
54908
- size: blob.size,
54909
- };
54910
- }
54911
- /**
54912
- * Crop an image with specific coordinates
54913
- * @param file - File or Blob to crop
54914
- * @param cropData - Crop coordinates and dimensions
54915
- * @param options - Optional compression options for output
54916
- * @returns Promise with cropped image
54917
- */
54918
- async crop(file, cropData, options) {
54919
- const img = await this.loadImage(file);
54920
- const opts = {
54921
- quality: options?.quality ?? IMAGE_DEFAULTS.quality,
54922
- mimeType: options?.mimeType ?? IMAGE_DEFAULTS.mimeType,
54923
- };
54924
- const canvas = document.createElement('canvas');
54925
- canvas.width = cropData.width;
54926
- canvas.height = cropData.height;
54927
- const ctx = canvas.getContext('2d');
54928
- ctx.drawImage(img, cropData.x, cropData.y, cropData.width, cropData.height, 0, 0, cropData.width, cropData.height);
54929
- // Apply max dimensions if specified
54930
- if (options?.maxWidth || options?.maxHeight) {
54931
- return this.compress(await this.canvasToBlob(canvas, opts.mimeType, 1), options);
54932
- }
54933
- const blob = await this.canvasToBlob(canvas, opts.mimeType, opts.quality);
54934
- const dataUrl = canvas.toDataURL(opts.mimeType, opts.quality);
54935
- return {
54936
- blob,
54937
- dataUrl,
54938
- width: cropData.width,
54939
- height: cropData.height,
54940
- size: blob.size,
54941
- };
54942
- }
54943
- /**
54944
- * Validate an image file before processing
54945
- * @param file - File to validate
54946
- * @param options - Validation options
54947
- * @returns Validation result with error details if invalid
54948
- */
54949
- validate(file, options) {
54950
- const opts = {
54951
- maxSize: options?.maxSize ?? IMAGE_DEFAULTS.maxSize,
54952
- allowedTypes: options?.allowedTypes ?? IMAGE_DEFAULTS.allowedTypes,
54953
- };
54954
- // Check file type
54955
- if (!opts.allowedTypes.includes(file.type)) {
54956
- return {
54957
- valid: false,
54958
- error: 'invalidType',
54959
- message: `Formato no válido. Usa: ${opts.allowedTypes.map(t => t.split('/')[1].toUpperCase()).join(', ')}`,
54960
- };
54961
- }
54962
- // Check file size
54963
- if (file.size > opts.maxSize) {
54964
- const maxMB = Math.round(opts.maxSize / (1024 * 1024));
54965
- return {
54966
- valid: false,
54967
- error: 'fileTooLarge',
54968
- message: `La imagen es muy grande. Máximo ${maxMB}MB`,
54969
- };
54970
- }
54971
- return { valid: true };
54972
- }
54973
- /**
54974
- * Validate image dimensions (async - requires loading image)
54975
- * @param file - File to validate
54976
- * @param options - Validation options with minWidth/minHeight
54977
- * @returns Promise with validation result
54978
- */
54979
- async validateDimensions(file, options) {
54980
- const img = await this.loadImage(file);
54981
- if (options.minWidth && img.width < options.minWidth) {
54982
- return {
54983
- valid: false,
54984
- error: 'imageTooSmall',
54985
- message: `La imagen debe tener al menos ${options.minWidth}px de ancho`,
54986
- };
54987
- }
54988
- if (options.minHeight && img.height < options.minHeight) {
54989
- return {
54990
- valid: false,
54991
- error: 'imageTooSmall',
54992
- message: `La imagen debe tener al menos ${options.minHeight}px de alto`,
54993
- };
54994
- }
54995
- return { valid: true };
54996
- }
54997
- /**
54998
- * Convert a Blob/File to a data URL
54999
- */
55000
- async toDataUrl(file) {
55001
- return new Promise((resolve, reject) => {
55002
- const reader = new FileReader();
55003
- reader.onload = () => resolve(reader.result);
55004
- reader.onerror = reject;
55005
- reader.readAsDataURL(file);
55006
- });
55007
- }
55008
- /**
55009
- * Convert a data URL to a Blob
55010
- */
55011
- dataUrlToBlob(dataUrl) {
55012
- const arr = dataUrl.split(',');
55013
- const mime = arr[0].match(/:(.*?);/)[1];
55014
- const bstr = atob(arr[1]);
55015
- let n = bstr.length;
55016
- const u8arr = new Uint8Array(n);
55017
- while (n--) {
55018
- u8arr[n] = bstr.charCodeAt(n);
55019
- }
55020
- return new Blob([u8arr], { type: mime });
55021
- }
55022
- // ============== Private Helpers ==============
55023
- loadImage(file) {
55024
- return new Promise((resolve, reject) => {
55025
- const img = new Image();
55026
- img.onload = () => {
55027
- URL.revokeObjectURL(img.src);
55028
- resolve(img);
55029
- };
55030
- img.onerror = reject;
55031
- img.src = URL.createObjectURL(file);
55032
- });
55033
- }
55034
- calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
55035
- let width = originalWidth;
55036
- let height = originalHeight;
55037
- // Scale down if necessary, maintaining aspect ratio
55038
- if (width > maxWidth) {
55039
- height = (height * maxWidth) / width;
55040
- width = maxWidth;
55041
- }
55042
- if (height > maxHeight) {
55043
- width = (width * maxHeight) / height;
55044
- height = maxHeight;
55045
- }
55046
- return {
55047
- width: Math.round(width),
55048
- height: Math.round(height),
55049
- };
55050
- }
55051
- canvasToBlob(canvas, mimeType, quality) {
55052
- return new Promise((resolve, reject) => {
55053
- canvas.toBlob((blob) => {
55054
- if (blob)
55055
- resolve(blob);
55056
- else
55057
- reject(new Error('Failed to create blob from canvas'));
55058
- }, mimeType, quality);
55059
- });
55060
- }
55061
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
55062
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, providedIn: 'root' }); }
55063
- }
55064
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, decorators: [{
55065
- type: Injectable,
55066
- args: [{ providedIn: 'root' }]
55067
- }] });
55068
-
55069
54988
  /**
55070
54989
  * Default values
55071
54990
  */