valtech-components 4.0.988 → 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.
@@ -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.988';
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');
@@ -38719,7 +39011,7 @@ const SURVEY_RESPONSE_I18N = {
38719
39011
  },
38720
39012
  };
38721
39013
 
38722
- const NAMESPACE$3 = 'SurveyResponse';
39014
+ const NAMESPACE$4 = 'SurveyResponse';
38723
39015
  /** Code del backend cuando la invitación de esa persona ya se respondió. */
38724
39016
  const INVITE_USED_CODE = 'SURVEY_INVITE_ALREADY_USED';
38725
39017
  /** Tipos cuya respuesta es un booleano, no un texto. */
@@ -38798,8 +39090,8 @@ class SurveyResponseComponent {
38798
39090
  this.i18n.lang();
38799
39091
  return { variant: 'error', title: this.t('loginRequiredTitle'), description: this.t('loginRequiredBody') };
38800
39092
  });
38801
- if (!this.i18n.hasNamespace(NAMESPACE$3)) {
38802
- this.i18n.registerDefaults(NAMESPACE$3, SURVEY_RESPONSE_I18N);
39093
+ if (!this.i18n.hasNamespace(NAMESPACE$4)) {
39094
+ this.i18n.registerDefaults(NAMESPACE$4, SURVEY_RESPONSE_I18N);
38803
39095
  }
38804
39096
  }
38805
39097
  async ngOnInit() {
@@ -38989,12 +39281,12 @@ class SurveyResponseComponent {
38989
39281
  this.errors.handle(err, {
38990
39282
  context: 'surveyResponse.submit',
38991
39283
  fallbackKey: 'sendError',
38992
- i18nNamespace: NAMESPACE$3,
39284
+ i18nNamespace: NAMESPACE$4,
38993
39285
  });
38994
39286
  }
38995
39287
  }
38996
39288
  t(key) {
38997
- return this.i18n.t(key, NAMESPACE$3);
39289
+ return this.i18n.t(key, NAMESPACE$4);
38998
39290
  }
38999
39291
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SurveyResponseComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
39000
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: `
@@ -39603,7 +39895,7 @@ const FIELD_SCHEMA_EDITOR_I18N = {
39603
39895
  },
39604
39896
  };
39605
39897
 
39606
- const NAMESPACE$2 = 'FieldSchemaEditor';
39898
+ const NAMESPACE$3 = 'FieldSchemaEditor';
39607
39899
  /**
39608
39900
  * Tipos cuyo valor sale de una lista cerrada que define quien arma el
39609
39901
  * formulario — sin al menos una opción, el campo no se puede responder.
@@ -39688,8 +39980,8 @@ class FieldSchemaEditorComponent {
39688
39980
  options: this.options(),
39689
39981
  };
39690
39982
  });
39691
- if (!this.i18n.hasNamespace(NAMESPACE$2)) {
39692
- 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);
39693
39985
  }
39694
39986
  this.typeControl.valueChanges.subscribe(value => {
39695
39987
  this.currentType.set(value || 'TEXT');
@@ -39726,7 +40018,7 @@ class FieldSchemaEditorComponent {
39726
40018
  }
39727
40019
  t(key) {
39728
40020
  this.i18n.lang();
39729
- return this.i18n.t(key, NAMESPACE$2);
40021
+ return this.i18n.t(key, NAMESPACE$3);
39730
40022
  }
39731
40023
  state() {
39732
40024
  return this.props.state ?? ComponentStates.ENABLED;
@@ -39953,6 +40245,379 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
39953
40245
  type: Output
39954
40246
  }] } });
39955
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
+
39956
40621
  class RequestFormBuilderService extends FormSchemaBuilderService {
39957
40622
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestFormBuilderService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
39958
40623
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestFormBuilderService, providedIn: 'root' }); }
@@ -44999,521 +45664,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
44999
45664
  type: Output
45000
45665
  }] } });
45001
45666
 
45002
- const STATUS_STYLE = {
45003
- approved: { bg: '#22c55e', color: '#fff' },
45004
- rejected: { bg: '#ef4444', color: '#fff' },
45005
- in_review: { bg: '#fef3c7', color: '#92400e' },
45006
- pending: { bg: 'var(--ion-color-light, #f4f5f8)', color: 'var(--ion-color-dark, #222)' },
45007
- cancelled: { bg: 'var(--ion-color-light, #f4f5f8)', color: 'var(--ion-color-medium, #636469)' },
45008
- closed: { bg: 'var(--ion-color-light, #f4f5f8)', color: 'var(--ion-color-medium, #636469)' },
45009
- };
45010
- /**
45011
- * Panel de revisión de una Request genérica (ADR-061/062): status + acciones
45012
- * approve/reject + fields del formulario + comentarios. Config-driven, sin
45013
- * conocimiento del dominio (adoption, recipe_verification, etc.) — cada
45014
- * consumer inyecta su `RequestReviewPanelConfig`.
45015
- */
45016
- class RequestReviewPanelComponent {
45017
- constructor() {
45018
- this.requests = inject(RequestService);
45019
- this.i18n = inject(I18nService);
45020
- this.errors = inject(ValtechErrorService);
45021
- this.toast = inject(ToastService);
45022
- this.destroyRef = inject(DestroyRef);
45023
- this.requestId = input.required();
45024
- this.config = input.required();
45025
- this.loaded = output();
45026
- this._req = signal(null);
45027
- this._loading = signal(true);
45028
- this._loadError = signal(null);
45029
- this._comments = signal([]);
45030
- this._loadingComments = signal(false);
45031
- this._transitioning = signal(false);
45032
- this.req = this._req.asReadonly();
45033
- this.loading = this._loading.asReadonly();
45034
- this.loadError = this._loadError.asReadonly();
45035
- this.comments = this._comments.asReadonly();
45036
- this.loadingComments = this._loadingComments.asReadonly();
45037
- this.transitioning = this._transitioning.asReadonly();
45038
- this.heroImgFailed = signal(false);
45039
- this.heroConfig = computed(() => this.config().entityHero);
45040
- this.statusStyle = computed(() => STATUS_STYLE[this._req()?.status ?? ''] ?? STATUS_STYLE['pending']);
45041
- this.heroName = computed(() => {
45042
- const hero = this.heroConfig();
45043
- if (!hero)
45044
- return undefined;
45045
- return this._req()?.metadata?.[hero.nameField];
45046
- });
45047
- this.heroImage = computed(() => {
45048
- const hero = this.heroConfig();
45049
- if (!hero?.imageField)
45050
- return undefined;
45051
- return this._req()?.metadata?.[hero.imageField];
45052
- });
45053
- this.canNavigateHero = computed(() => {
45054
- const hero = this.heroConfig();
45055
- const metadata = this._req()?.metadata;
45056
- if (!hero || !metadata)
45057
- return false;
45058
- return hero.canNavigate(metadata);
45059
- });
45060
- this.submitterInitial = computed(() => {
45061
- const s = this._req()?.submitter;
45062
- if (!s)
45063
- return '?';
45064
- return ((s.name || s.email) ?? '?').charAt(0).toUpperCase();
45065
- });
45066
- this.fieldEntries = computed(() => {
45067
- const fields = this._req()?.fields ?? {};
45068
- return Object.entries(fields)
45069
- .filter(([, v]) => v != null && v !== '')
45070
- .map(([k, v]) => ({ key: k, value: String(v) }));
45071
- });
45072
- this.errorState = computed(() => {
45073
- this.i18n.lang();
45074
- const err = this._loadError();
45075
- const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase();
45076
- const isOffline = msg.includes('network') || msg.includes('offline');
45077
- return {
45078
- variant: 'error',
45079
- title: isOffline ? this.t('offlineTitle') : this.t('errorTitle'),
45080
- description: isOffline ? this.t('offlineHint') : '',
45081
- cta: { label: this.t('retry'), handler: () => void this.load() },
45082
- };
45083
- });
45084
- }
45085
- ngOnInit() {
45086
- void this.load();
45087
- }
45088
- async load() {
45089
- const id = this.requestId();
45090
- this._loading.set(true);
45091
- this._loadError.set(null);
45092
- try {
45093
- const req = await firstValueFrom(this.requests.getRequest(id));
45094
- this._req.set(req);
45095
- this.loaded.emit(req);
45096
- this.subscribeComments(req.appId, req.orgId, id);
45097
- }
45098
- catch (err) {
45099
- this._loadError.set(err);
45100
- this.errors.handle(err, {
45101
- context: 'request-review-panel.load',
45102
- fallbackKey: 'errorTitle',
45103
- i18nNamespace: this.config().i18nNamespace,
45104
- });
45105
- }
45106
- finally {
45107
- this._loading.set(false);
45108
- }
45109
- }
45110
- subscribeComments(appId, orgId, requestId) {
45111
- this._loadingComments.set(true);
45112
- this.requests
45113
- .watchComments(appId, orgId, requestId)
45114
- .pipe(takeUntilDestroyed(this.destroyRef))
45115
- .subscribe({
45116
- next: comments => {
45117
- this._comments.set(comments);
45118
- this._loadingComments.set(false);
45119
- },
45120
- error: () => {
45121
- // comentarios son best-effort; si el listener falla, silenciar y ocultar spinner
45122
- this._loadingComments.set(false);
45123
- },
45124
- });
45125
- }
45126
- async transition(status) {
45127
- const req = this._req();
45128
- if (!req || this._transitioning())
45129
- return;
45130
- this._transitioning.set(true);
45131
- try {
45132
- await firstValueFrom(this.requests.transition(req.id, { status }));
45133
- await this.load();
45134
- this.toast.show({ message: this.t('statusUpdated'), duration: 2500, color: 'dark', position: 'top' });
45135
- }
45136
- catch (err) {
45137
- this.errors.handle(err, {
45138
- context: 'request-review-panel.transition',
45139
- fallbackKey: 'statusError',
45140
- i18nNamespace: this.config().i18nNamespace,
45141
- });
45142
- }
45143
- finally {
45144
- this._transitioning.set(false);
45145
- }
45146
- }
45147
- onHeroClick() {
45148
- const hero = this.heroConfig();
45149
- const metadata = this._req()?.metadata;
45150
- if (hero && metadata)
45151
- hero.navigate(metadata);
45152
- }
45153
- formatDate(iso) {
45154
- if (!iso)
45155
- return '';
45156
- try {
45157
- return new Date(iso).toLocaleDateString(this.i18n.lang() === 'es' ? 'es-CL' : 'en-US', {
45158
- day: 'numeric',
45159
- month: 'short',
45160
- year: 'numeric',
45161
- });
45162
- }
45163
- catch {
45164
- return iso;
45165
- }
45166
- }
45167
- authorInitial(c) {
45168
- return ((c.author.name || c.author.email) ?? '?').charAt(0).toUpperCase();
45169
- }
45170
- relativeTime(iso) {
45171
- if (!iso)
45172
- return '';
45173
- const diffMs = Date.now() - new Date(iso).getTime();
45174
- if (Number.isNaN(diffMs))
45175
- return iso;
45176
- const lang = this.i18n.lang();
45177
- const mins = Math.floor(diffMs / 60000);
45178
- if (mins < 1)
45179
- return lang === 'es' ? 'hace un momento' : 'just now';
45180
- if (mins < 60)
45181
- return lang === 'es' ? `hace ${mins} min` : `${mins}m ago`;
45182
- const hours = Math.floor(mins / 60);
45183
- if (hours < 24)
45184
- return lang === 'es' ? `hace ${hours} h` : `${hours}h ago`;
45185
- const days = Math.floor(hours / 24);
45186
- if (days < 7)
45187
- return lang === 'es' ? `hace ${days} dia${days > 1 ? 's' : ''}` : `${days}d ago`;
45188
- return this.formatDate(iso);
45189
- }
45190
- t(key) {
45191
- return this.i18n.t(key, this.config().i18nNamespace);
45192
- }
45193
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestReviewPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
45194
- 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: `
45195
- <div class="page">
45196
- @if (loading()) {
45197
- <div class="skel" aria-hidden="true">
45198
- <val-skeleton [props]="{ width: '100%', height: '88px', borderRadius: '14px' }" />
45199
- <val-skeleton [props]="{ width: '100%', height: '64px', borderRadius: '12px' }" />
45200
- <val-skeleton [props]="{ width: '100%', height: '80px', borderRadius: '12px' }" />
45201
- <val-skeleton [props]="{ width: '100%', height: '120px', borderRadius: '12px' }" />
45202
- </div>
45203
- } @else if (loadError()) {
45204
- <val-empty-state [props]="errorState()" />
45205
- } @else if (req()) {
45206
- <div class="detail">
45207
- @if (heroConfig() && heroName()) {
45208
- <button class="entity-hero" (click)="onHeroClick()" [disabled]="!canNavigateHero()">
45209
- <div class="entity-hero__media">
45210
- @if (heroImage() && !heroImgFailed()) {
45211
- <img
45212
- [src]="heroImage()"
45213
- [alt]="heroName()!"
45214
- loading="lazy"
45215
- (error)="heroImgFailed.set(true)"
45216
- class="entity-hero__img"
45217
- />
45218
- } @else {
45219
- <span class="entity-hero__placeholder" aria-hidden="true">{{
45220
- heroConfig()!.placeholderIcon || '📄'
45221
- }}</span>
45222
- }
45223
- </div>
45224
- <div class="entity-hero__body">
45225
- <span class="entity-hero__label">{{ t(heroConfig()!.labelKey) }}</span>
45226
- <span class="entity-hero__name">{{ heroName() }}</span>
45227
- </div>
45228
- @if (canNavigateHero()) {
45229
- <span class="entity-hero__chevron" aria-hidden="true">›</span>
45230
- }
45231
- </button>
45232
- }
45233
-
45234
- <div class="status-card" [style.border-left-color]="statusStyle().bg">
45235
- <div class="status-card__top">
45236
- <span class="status-badge" [style.background]="statusStyle().bg" [style.color]="statusStyle().color">
45237
- {{ t('status_' + req()!.status) }}
45238
- </span>
45239
- <span class="submitted-at">{{ t('submittedAt') }}: {{ formatDate(req()!.createdAt) }}</span>
45240
- </div>
45241
-
45242
- @if (config().canReview() && (req()!.status === 'pending' || req()!.status === 'in_review')) {
45243
- <div class="actions-row">
45244
- <val-button
45245
- [props]="{
45246
- token: 'approve',
45247
- text: t('approve'),
45248
- color: 'primary',
45249
- fill: 'solid',
45250
- shape: 'round',
45251
- size: 'small',
45252
- type: 'button',
45253
- state: transitioning() ? 'WORKING' : 'ENABLED',
45254
- handler: transition.bind(this, 'approved'),
45255
- }"
45256
- />
45257
- <val-button
45258
- [props]="{
45259
- token: 'reject',
45260
- text: t('reject'),
45261
- color: 'medium',
45262
- fill: 'outline',
45263
- shape: 'round',
45264
- size: 'small',
45265
- type: 'button',
45266
- state: transitioning() ? 'WORKING' : 'ENABLED',
45267
- handler: transition.bind(this, 'rejected'),
45268
- }"
45269
- />
45270
- </div>
45271
- }
45272
- </div>
45273
-
45274
- @if (req()!.submitter) {
45275
- <div class="section">
45276
- <p class="section__title">{{ t('submitter') }}</p>
45277
- <div class="submitter-row">
45278
- <div class="submitter-avatar" aria-hidden="true">{{ submitterInitial() }}</div>
45279
- <div class="submitter-info">
45280
- @if (req()!.submitter!.name) {
45281
- <span class="submitter-name">{{ req()!.submitter!.name }}</span>
45282
- }
45283
- @if (req()!.submitter!.email) {
45284
- <span class="submitter-email">{{ req()!.submitter!.email }}</span>
45285
- }
45286
- @if (req()!.submitter!.phone) {
45287
- <span class="submitter-email">{{ req()!.submitter!.phone }}</span>
45288
- }
45289
- </div>
45290
- </div>
45291
- </div>
45292
- }
45293
-
45294
- @if (fieldEntries().length) {
45295
- <div class="section">
45296
- <p class="section__title">{{ t('fields') }}</p>
45297
- @for (entry of fieldEntries(); track entry.key) {
45298
- <div class="field-row">
45299
- <span class="field-row__label">{{ entry.key }}</span>
45300
- <span class="field-row__value">{{ entry.value }}</span>
45301
- </div>
45302
- }
45303
- </div>
45304
- }
45305
-
45306
- @if (
45307
- !config().canReview() && req()!.status === 'approved' && canNavigateHero() && config().viewEntityCtaLabelKey
45308
- ) {
45309
- <val-button
45310
- [props]="{
45311
- token: 'view-entity',
45312
- text: t(config().viewEntityCtaLabelKey!),
45313
- color: 'primary',
45314
- fill: 'solid',
45315
- shape: 'round',
45316
- size: 'default',
45317
- type: 'button',
45318
- state: 'ENABLED',
45319
- handler: onHeroClick.bind(this),
45320
- }"
45321
- />
45322
- }
45323
-
45324
- <div class="section">
45325
- <p class="section__title">{{ t('commentsTitle') }}</p>
45326
- @if (loadingComments()) {
45327
- <div class="section-pad">
45328
- <val-skeleton [props]="{ width: '100%', height: '48px', borderRadius: '8px' }" />
45329
- </div>
45330
- } @else if (comments().length === 0) {
45331
- <p class="no-comments">{{ t('noComments') }}</p>
45332
- } @else {
45333
- <ul class="comments">
45334
- @for (c of comments(); track c.commentId) {
45335
- <li class="comment">
45336
- <div class="comment__avatar" aria-hidden="true">{{ authorInitial(c) }}</div>
45337
- <div class="comment__content">
45338
- <div class="comment__header">
45339
- <span class="comment__author">{{ c.author.name || c.author.email }}</span>
45340
- <span class="comment__date">{{ relativeTime(c.createdAt) }}</span>
45341
- </div>
45342
- <p class="comment__body">{{ c.body }}</p>
45343
- </div>
45344
- </li>
45345
- }
45346
- </ul>
45347
- }
45348
- </div>
45349
- </div>
45350
- }
45351
- </div>
45352
- `, 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"] }] }); }
45353
- }
45354
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: RequestReviewPanelComponent, decorators: [{
45355
- type: Component,
45356
- args: [{ selector: 'val-request-review-panel', standalone: true, imports: [ButtonComponent, EmptyStateComponent, SkeletonComponent], template: `
45357
- <div class="page">
45358
- @if (loading()) {
45359
- <div class="skel" aria-hidden="true">
45360
- <val-skeleton [props]="{ width: '100%', height: '88px', borderRadius: '14px' }" />
45361
- <val-skeleton [props]="{ width: '100%', height: '64px', borderRadius: '12px' }" />
45362
- <val-skeleton [props]="{ width: '100%', height: '80px', borderRadius: '12px' }" />
45363
- <val-skeleton [props]="{ width: '100%', height: '120px', borderRadius: '12px' }" />
45364
- </div>
45365
- } @else if (loadError()) {
45366
- <val-empty-state [props]="errorState()" />
45367
- } @else if (req()) {
45368
- <div class="detail">
45369
- @if (heroConfig() && heroName()) {
45370
- <button class="entity-hero" (click)="onHeroClick()" [disabled]="!canNavigateHero()">
45371
- <div class="entity-hero__media">
45372
- @if (heroImage() && !heroImgFailed()) {
45373
- <img
45374
- [src]="heroImage()"
45375
- [alt]="heroName()!"
45376
- loading="lazy"
45377
- (error)="heroImgFailed.set(true)"
45378
- class="entity-hero__img"
45379
- />
45380
- } @else {
45381
- <span class="entity-hero__placeholder" aria-hidden="true">{{
45382
- heroConfig()!.placeholderIcon || '📄'
45383
- }}</span>
45384
- }
45385
- </div>
45386
- <div class="entity-hero__body">
45387
- <span class="entity-hero__label">{{ t(heroConfig()!.labelKey) }}</span>
45388
- <span class="entity-hero__name">{{ heroName() }}</span>
45389
- </div>
45390
- @if (canNavigateHero()) {
45391
- <span class="entity-hero__chevron" aria-hidden="true">›</span>
45392
- }
45393
- </button>
45394
- }
45395
-
45396
- <div class="status-card" [style.border-left-color]="statusStyle().bg">
45397
- <div class="status-card__top">
45398
- <span class="status-badge" [style.background]="statusStyle().bg" [style.color]="statusStyle().color">
45399
- {{ t('status_' + req()!.status) }}
45400
- </span>
45401
- <span class="submitted-at">{{ t('submittedAt') }}: {{ formatDate(req()!.createdAt) }}</span>
45402
- </div>
45403
-
45404
- @if (config().canReview() && (req()!.status === 'pending' || req()!.status === 'in_review')) {
45405
- <div class="actions-row">
45406
- <val-button
45407
- [props]="{
45408
- token: 'approve',
45409
- text: t('approve'),
45410
- color: 'primary',
45411
- fill: 'solid',
45412
- shape: 'round',
45413
- size: 'small',
45414
- type: 'button',
45415
- state: transitioning() ? 'WORKING' : 'ENABLED',
45416
- handler: transition.bind(this, 'approved'),
45417
- }"
45418
- />
45419
- <val-button
45420
- [props]="{
45421
- token: 'reject',
45422
- text: t('reject'),
45423
- color: 'medium',
45424
- fill: 'outline',
45425
- shape: 'round',
45426
- size: 'small',
45427
- type: 'button',
45428
- state: transitioning() ? 'WORKING' : 'ENABLED',
45429
- handler: transition.bind(this, 'rejected'),
45430
- }"
45431
- />
45432
- </div>
45433
- }
45434
- </div>
45435
-
45436
- @if (req()!.submitter) {
45437
- <div class="section">
45438
- <p class="section__title">{{ t('submitter') }}</p>
45439
- <div class="submitter-row">
45440
- <div class="submitter-avatar" aria-hidden="true">{{ submitterInitial() }}</div>
45441
- <div class="submitter-info">
45442
- @if (req()!.submitter!.name) {
45443
- <span class="submitter-name">{{ req()!.submitter!.name }}</span>
45444
- }
45445
- @if (req()!.submitter!.email) {
45446
- <span class="submitter-email">{{ req()!.submitter!.email }}</span>
45447
- }
45448
- @if (req()!.submitter!.phone) {
45449
- <span class="submitter-email">{{ req()!.submitter!.phone }}</span>
45450
- }
45451
- </div>
45452
- </div>
45453
- </div>
45454
- }
45455
-
45456
- @if (fieldEntries().length) {
45457
- <div class="section">
45458
- <p class="section__title">{{ t('fields') }}</p>
45459
- @for (entry of fieldEntries(); track entry.key) {
45460
- <div class="field-row">
45461
- <span class="field-row__label">{{ entry.key }}</span>
45462
- <span class="field-row__value">{{ entry.value }}</span>
45463
- </div>
45464
- }
45465
- </div>
45466
- }
45467
-
45468
- @if (
45469
- !config().canReview() && req()!.status === 'approved' && canNavigateHero() && config().viewEntityCtaLabelKey
45470
- ) {
45471
- <val-button
45472
- [props]="{
45473
- token: 'view-entity',
45474
- text: t(config().viewEntityCtaLabelKey!),
45475
- color: 'primary',
45476
- fill: 'solid',
45477
- shape: 'round',
45478
- size: 'default',
45479
- type: 'button',
45480
- state: 'ENABLED',
45481
- handler: onHeroClick.bind(this),
45482
- }"
45483
- />
45484
- }
45485
-
45486
- <div class="section">
45487
- <p class="section__title">{{ t('commentsTitle') }}</p>
45488
- @if (loadingComments()) {
45489
- <div class="section-pad">
45490
- <val-skeleton [props]="{ width: '100%', height: '48px', borderRadius: '8px' }" />
45491
- </div>
45492
- } @else if (comments().length === 0) {
45493
- <p class="no-comments">{{ t('noComments') }}</p>
45494
- } @else {
45495
- <ul class="comments">
45496
- @for (c of comments(); track c.commentId) {
45497
- <li class="comment">
45498
- <div class="comment__avatar" aria-hidden="true">{{ authorInitial(c) }}</div>
45499
- <div class="comment__content">
45500
- <div class="comment__header">
45501
- <span class="comment__author">{{ c.author.name || c.author.email }}</span>
45502
- <span class="comment__date">{{ relativeTime(c.createdAt) }}</span>
45503
- </div>
45504
- <p class="comment__body">{{ c.body }}</p>
45505
- </div>
45506
- </li>
45507
- }
45508
- </ul>
45509
- }
45510
- </div>
45511
- </div>
45512
- }
45513
- </div>
45514
- `, 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"] }]
45515
- }], ctorParameters: () => [] });
45516
-
45517
45667
  /**
45518
45668
  * `val-cookie-banner` — bottom/top fixed banner asking the user to choose
45519
45669
  * a cookie consent option. Presentational only: emits events on each
@@ -54835,260 +54985,6 @@ function buildSideNavItemsFromBottomNav(config, options = {}) {
54835
54985
  }));
54836
54986
  }
54837
54987
 
54838
- /**
54839
- * Default values for image processing
54840
- */
54841
- const IMAGE_DEFAULTS = {
54842
- maxWidth: 800,
54843
- maxHeight: 800,
54844
- quality: 0.8,
54845
- mimeType: 'image/jpeg',
54846
- maxSize: 10 * 1024 * 1024, // 10MB
54847
- allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
54848
- thumbnailSize: 150,
54849
- };
54850
-
54851
- /**
54852
- * ImageService
54853
- *
54854
- * Service for image processing including compression, thumbnails, cropping and validation.
54855
- * Uses HTML Canvas for all operations - no external dependencies.
54856
- *
54857
- * @example
54858
- * ```typescript
54859
- * const imageService = inject(ImageService);
54860
- *
54861
- * // Compress an image
54862
- * const compressed = await imageService.compress(file, { maxWidth: 800, quality: 0.8 });
54863
- *
54864
- * // Generate thumbnail
54865
- * const thumb = await imageService.thumbnail(file, 150);
54866
- *
54867
- * // Validate before processing
54868
- * const validation = imageService.validate(file, { maxSize: 5 * 1024 * 1024 });
54869
- * if (!validation.valid) {
54870
- * console.error(validation.message);
54871
- * }
54872
- * ```
54873
- */
54874
- class ImageService {
54875
- /**
54876
- * Compress an image maintaining aspect ratio
54877
- * @param file - File or Blob to compress
54878
- * @param options - Compression options
54879
- * @returns Promise with processed image data
54880
- */
54881
- async compress(file, options) {
54882
- const opts = {
54883
- maxWidth: options?.maxWidth ?? IMAGE_DEFAULTS.maxWidth,
54884
- maxHeight: options?.maxHeight ?? IMAGE_DEFAULTS.maxHeight,
54885
- quality: options?.quality ?? IMAGE_DEFAULTS.quality,
54886
- mimeType: options?.mimeType ?? IMAGE_DEFAULTS.mimeType,
54887
- };
54888
- const img = await this.loadImage(file);
54889
- const { width, height } = this.calculateDimensions(img.width, img.height, opts.maxWidth, opts.maxHeight);
54890
- const canvas = document.createElement('canvas');
54891
- canvas.width = width;
54892
- canvas.height = height;
54893
- const ctx = canvas.getContext('2d');
54894
- ctx.drawImage(img, 0, 0, width, height);
54895
- const blob = await this.canvasToBlob(canvas, opts.mimeType, opts.quality);
54896
- const dataUrl = canvas.toDataURL(opts.mimeType, opts.quality);
54897
- return {
54898
- blob,
54899
- dataUrl,
54900
- width,
54901
- height,
54902
- size: blob.size,
54903
- };
54904
- }
54905
- /**
54906
- * Generate a square thumbnail from an image
54907
- * @param file - File or Blob to process
54908
- * @param size - Thumbnail size in pixels (default: 150)
54909
- * @returns Promise with processed thumbnail
54910
- */
54911
- async thumbnail(file, size) {
54912
- const thumbSize = size ?? IMAGE_DEFAULTS.thumbnailSize;
54913
- const img = await this.loadImage(file);
54914
- // Calculate square crop from center
54915
- const minDim = Math.min(img.width, img.height);
54916
- const cropX = (img.width - minDim) / 2;
54917
- const cropY = (img.height - minDim) / 2;
54918
- const canvas = document.createElement('canvas');
54919
- canvas.width = thumbSize;
54920
- canvas.height = thumbSize;
54921
- const ctx = canvas.getContext('2d');
54922
- ctx.drawImage(img, cropX, cropY, minDim, minDim, 0, 0, thumbSize, thumbSize);
54923
- const blob = await this.canvasToBlob(canvas, IMAGE_DEFAULTS.mimeType, 0.7 // Lower quality for thumbnails
54924
- );
54925
- const dataUrl = canvas.toDataURL(IMAGE_DEFAULTS.mimeType, 0.7);
54926
- return {
54927
- blob,
54928
- dataUrl,
54929
- width: thumbSize,
54930
- height: thumbSize,
54931
- size: blob.size,
54932
- };
54933
- }
54934
- /**
54935
- * Crop an image with specific coordinates
54936
- * @param file - File or Blob to crop
54937
- * @param cropData - Crop coordinates and dimensions
54938
- * @param options - Optional compression options for output
54939
- * @returns Promise with cropped image
54940
- */
54941
- async crop(file, cropData, options) {
54942
- const img = await this.loadImage(file);
54943
- const opts = {
54944
- quality: options?.quality ?? IMAGE_DEFAULTS.quality,
54945
- mimeType: options?.mimeType ?? IMAGE_DEFAULTS.mimeType,
54946
- };
54947
- const canvas = document.createElement('canvas');
54948
- canvas.width = cropData.width;
54949
- canvas.height = cropData.height;
54950
- const ctx = canvas.getContext('2d');
54951
- ctx.drawImage(img, cropData.x, cropData.y, cropData.width, cropData.height, 0, 0, cropData.width, cropData.height);
54952
- // Apply max dimensions if specified
54953
- if (options?.maxWidth || options?.maxHeight) {
54954
- return this.compress(await this.canvasToBlob(canvas, opts.mimeType, 1), options);
54955
- }
54956
- const blob = await this.canvasToBlob(canvas, opts.mimeType, opts.quality);
54957
- const dataUrl = canvas.toDataURL(opts.mimeType, opts.quality);
54958
- return {
54959
- blob,
54960
- dataUrl,
54961
- width: cropData.width,
54962
- height: cropData.height,
54963
- size: blob.size,
54964
- };
54965
- }
54966
- /**
54967
- * Validate an image file before processing
54968
- * @param file - File to validate
54969
- * @param options - Validation options
54970
- * @returns Validation result with error details if invalid
54971
- */
54972
- validate(file, options) {
54973
- const opts = {
54974
- maxSize: options?.maxSize ?? IMAGE_DEFAULTS.maxSize,
54975
- allowedTypes: options?.allowedTypes ?? IMAGE_DEFAULTS.allowedTypes,
54976
- };
54977
- // Check file type
54978
- if (!opts.allowedTypes.includes(file.type)) {
54979
- return {
54980
- valid: false,
54981
- error: 'invalidType',
54982
- message: `Formato no válido. Usa: ${opts.allowedTypes.map(t => t.split('/')[1].toUpperCase()).join(', ')}`,
54983
- };
54984
- }
54985
- // Check file size
54986
- if (file.size > opts.maxSize) {
54987
- const maxMB = Math.round(opts.maxSize / (1024 * 1024));
54988
- return {
54989
- valid: false,
54990
- error: 'fileTooLarge',
54991
- message: `La imagen es muy grande. Máximo ${maxMB}MB`,
54992
- };
54993
- }
54994
- return { valid: true };
54995
- }
54996
- /**
54997
- * Validate image dimensions (async - requires loading image)
54998
- * @param file - File to validate
54999
- * @param options - Validation options with minWidth/minHeight
55000
- * @returns Promise with validation result
55001
- */
55002
- async validateDimensions(file, options) {
55003
- const img = await this.loadImage(file);
55004
- if (options.minWidth && img.width < options.minWidth) {
55005
- return {
55006
- valid: false,
55007
- error: 'imageTooSmall',
55008
- message: `La imagen debe tener al menos ${options.minWidth}px de ancho`,
55009
- };
55010
- }
55011
- if (options.minHeight && img.height < options.minHeight) {
55012
- return {
55013
- valid: false,
55014
- error: 'imageTooSmall',
55015
- message: `La imagen debe tener al menos ${options.minHeight}px de alto`,
55016
- };
55017
- }
55018
- return { valid: true };
55019
- }
55020
- /**
55021
- * Convert a Blob/File to a data URL
55022
- */
55023
- async toDataUrl(file) {
55024
- return new Promise((resolve, reject) => {
55025
- const reader = new FileReader();
55026
- reader.onload = () => resolve(reader.result);
55027
- reader.onerror = reject;
55028
- reader.readAsDataURL(file);
55029
- });
55030
- }
55031
- /**
55032
- * Convert a data URL to a Blob
55033
- */
55034
- dataUrlToBlob(dataUrl) {
55035
- const arr = dataUrl.split(',');
55036
- const mime = arr[0].match(/:(.*?);/)[1];
55037
- const bstr = atob(arr[1]);
55038
- let n = bstr.length;
55039
- const u8arr = new Uint8Array(n);
55040
- while (n--) {
55041
- u8arr[n] = bstr.charCodeAt(n);
55042
- }
55043
- return new Blob([u8arr], { type: mime });
55044
- }
55045
- // ============== Private Helpers ==============
55046
- loadImage(file) {
55047
- return new Promise((resolve, reject) => {
55048
- const img = new Image();
55049
- img.onload = () => {
55050
- URL.revokeObjectURL(img.src);
55051
- resolve(img);
55052
- };
55053
- img.onerror = reject;
55054
- img.src = URL.createObjectURL(file);
55055
- });
55056
- }
55057
- calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
55058
- let width = originalWidth;
55059
- let height = originalHeight;
55060
- // Scale down if necessary, maintaining aspect ratio
55061
- if (width > maxWidth) {
55062
- height = (height * maxWidth) / width;
55063
- width = maxWidth;
55064
- }
55065
- if (height > maxHeight) {
55066
- width = (width * maxHeight) / height;
55067
- height = maxHeight;
55068
- }
55069
- return {
55070
- width: Math.round(width),
55071
- height: Math.round(height),
55072
- };
55073
- }
55074
- canvasToBlob(canvas, mimeType, quality) {
55075
- return new Promise((resolve, reject) => {
55076
- canvas.toBlob((blob) => {
55077
- if (blob)
55078
- resolve(blob);
55079
- else
55080
- reject(new Error('Failed to create blob from canvas'));
55081
- }, mimeType, quality);
55082
- });
55083
- }
55084
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
55085
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, providedIn: 'root' }); }
55086
- }
55087
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ImageService, decorators: [{
55088
- type: Injectable,
55089
- args: [{ providedIn: 'root' }]
55090
- }] });
55091
-
55092
54988
  /**
55093
54989
  * Default values
55094
54990
  */