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.
- package/esm2022/lib/components/organisms/attachment-uploader/attachment-uploader.component.mjs +41 -2
- package/esm2022/lib/components/organisms/attachment-uploader/types.mjs +1 -1
- package/esm2022/lib/components/organisms/request-review-panel/request-review-panel.component.mjs +316 -481
- package/esm2022/lib/components/organisms/request-review-panel/request-review-panel.i18n.mjs +21 -0
- package/esm2022/lib/components/organisms/request-review-panel/types.mjs +2 -0
- package/esm2022/lib/components/organisms/survey-response/survey-response.component.mjs +21 -1
- package/esm2022/lib/components/organisms/survey-response/survey-response.i18n.mjs +5 -1
- package/esm2022/lib/components/organisms/survey-response/types.mjs +1 -1
- package/esm2022/lib/services/requests/types.mjs +1 -1
- package/esm2022/lib/version.mjs +2 -2
- package/esm2022/public-api.mjs +3 -2
- package/fesm2022/valtech-components.mjs +699 -780
- package/fesm2022/valtech-components.mjs.map +1 -1
- package/lib/components/organisms/attachment-uploader/attachment-uploader.component.d.ts +14 -0
- package/lib/components/organisms/attachment-uploader/types.d.ts +30 -0
- package/lib/components/organisms/request-review-panel/request-review-panel.component.d.ts +72 -76
- package/lib/components/organisms/request-review-panel/request-review-panel.i18n.d.ts +2 -0
- package/lib/components/organisms/request-review-panel/types.d.ts +73 -0
- package/lib/components/organisms/survey-response/survey-response.component.d.ts +2 -0
- package/lib/components/organisms/survey-response/types.d.ts +6 -0
- package/lib/services/requests/types.d.ts +6 -0
- package/lib/version.d.ts +1 -1
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
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$
|
|
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$
|
|
38791
|
-
this.i18n.registerDefaults(NAMESPACE$
|
|
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$
|
|
39284
|
+
i18nNamespace: NAMESPACE$4,
|
|
38974
39285
|
});
|
|
38975
39286
|
}
|
|
38976
39287
|
}
|
|
38977
39288
|
t(key) {
|
|
38978
|
-
return this.i18n.t(key, NAMESPACE$
|
|
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$
|
|
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$
|
|
39669
|
-
this.i18n.registerDefaults(NAMESPACE$
|
|
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$
|
|
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
|
*/
|