tango-app-api-trax 3.9.35 → 3.9.37
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/package.json +1 -1
- package/src/controllers/internalTrax.controller.js +84 -5
- package/src/controllers/trax.controller.js +1 -1
- package/src/controllers/traxDashboard.controllers.js +11 -6
- package/src/hbs/visit-checklist.hbs +39 -21
- package/src/routes/internalTraxApi.router.js +1 -0
- package/src/utils/visitChecklistPdf.utils.js +268 -16
package/package.json
CHANGED
|
@@ -1703,6 +1703,85 @@ export async function saleUpdateCollection( req, res ) {
|
|
|
1703
1703
|
}
|
|
1704
1704
|
};
|
|
1705
1705
|
|
|
1706
|
+
/**
|
|
1707
|
+
* One-off migration: in checklistquestionsconfigs, when a question's answerType
|
|
1708
|
+
* is 'multiplechoicesingle' and one of its answers has validationType
|
|
1709
|
+
* 'Capture Image', replace it with 'Capture Multiple Image with description'.
|
|
1710
|
+
*
|
|
1711
|
+
* Document shape: { question: [ { answerType, answers: [ { validationType } ] } ] }
|
|
1712
|
+
* Defaults to a dry run; pass apply:true (body) or ?apply=true to write changes.
|
|
1713
|
+
* @param {Object} req Express request — body.apply / query.apply toggles writing
|
|
1714
|
+
* @param {Object} res Express response
|
|
1715
|
+
* @return {Promise<void>}
|
|
1716
|
+
*/
|
|
1717
|
+
export async function updateMultipleChoiceSingleValidationType( req, res ) {
|
|
1718
|
+
try {
|
|
1719
|
+
const TARGET_ANSWER_TYPE = 'multiplechoicesingle';
|
|
1720
|
+
const FROM_VALIDATION_TYPE = 'Capture Image';
|
|
1721
|
+
const TO_VALIDATION_TYPE = 'Capture Multiple Image with description';
|
|
1722
|
+
|
|
1723
|
+
const apply = req.body?.apply === true || req.query?.apply === 'true';
|
|
1724
|
+
|
|
1725
|
+
// Only load docs that actually contain a matching question + answer.
|
|
1726
|
+
const query = {
|
|
1727
|
+
question: {
|
|
1728
|
+
$elemMatch: {
|
|
1729
|
+
answerType: TARGET_ANSWER_TYPE,
|
|
1730
|
+
answers: { $elemMatch: { validationType: FROM_VALIDATION_TYPE } },
|
|
1731
|
+
},
|
|
1732
|
+
},
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
const docs = await CLquestions.find( query );
|
|
1736
|
+
|
|
1737
|
+
let documentsUpdated = 0;
|
|
1738
|
+
let answersUpdated = 0;
|
|
1739
|
+
const updatedIds = [];
|
|
1740
|
+
|
|
1741
|
+
for ( const doc of docs ) {
|
|
1742
|
+
const questions = Array.isArray( doc.question ) ? doc.question : [];
|
|
1743
|
+
let changedInDoc = 0;
|
|
1744
|
+
|
|
1745
|
+
for ( const q of questions ) {
|
|
1746
|
+
if ( !q || q.answerType !== TARGET_ANSWER_TYPE ) continue;
|
|
1747
|
+
|
|
1748
|
+
const answers = Array.isArray( q.answers ) ? q.answers : [];
|
|
1749
|
+
for ( const a of answers ) {
|
|
1750
|
+
if ( a && a.validationType === FROM_VALIDATION_TYPE ) {
|
|
1751
|
+
a.validationType = TO_VALIDATION_TYPE;
|
|
1752
|
+
changedInDoc++;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if ( changedInDoc > 0 ) {
|
|
1758
|
+
documentsUpdated++;
|
|
1759
|
+
answersUpdated += changedInDoc;
|
|
1760
|
+
updatedIds.push( doc._id );
|
|
1761
|
+
|
|
1762
|
+
// `question` is a Mixed array, so write the whole rebuilt array.
|
|
1763
|
+
if ( apply ) {
|
|
1764
|
+
await CLquestions.updateMany( { _id: doc._id }, { question: questions } );
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
return res.sendSuccess( {
|
|
1770
|
+
message: apply ?
|
|
1771
|
+
'Validation type updated successfully' :
|
|
1772
|
+
'Dry run — pass apply:true to write these changes',
|
|
1773
|
+
apply,
|
|
1774
|
+
matchedDocuments: docs.length,
|
|
1775
|
+
documentsUpdated,
|
|
1776
|
+
answersUpdated,
|
|
1777
|
+
updatedIds,
|
|
1778
|
+
} );
|
|
1779
|
+
} catch ( error ) {
|
|
1780
|
+
logger.error( { function: 'updateMultipleChoiceSingleValidationType', error: error } );
|
|
1781
|
+
return res.sendError( error, 500 );
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1706
1785
|
export async function getUserStoreList( req, res ) {
|
|
1707
1786
|
try {
|
|
1708
1787
|
let details = [];
|
|
@@ -2689,16 +2768,16 @@ export async function countUpdateRunAI( req, res ) {
|
|
|
2689
2768
|
if ( !req.body.id ) {
|
|
2690
2769
|
return res.sendError( 'Checklist id is required', 400 );
|
|
2691
2770
|
}
|
|
2692
|
-
if ( !req.body.runAICount ) {
|
|
2693
|
-
|
|
2694
|
-
}
|
|
2771
|
+
// if ( !req.body.runAICount ) {
|
|
2772
|
+
// return res.sendError( 'runAICount is required', 400 );
|
|
2773
|
+
// }
|
|
2695
2774
|
let getDetails = await processedchecklist.findOne( { _id: req.body.id } );
|
|
2696
2775
|
if ( !getDetails ) {
|
|
2697
2776
|
return res.sendError( 'No data found', 204 );
|
|
2698
2777
|
}
|
|
2699
2778
|
|
|
2700
2779
|
let updateData = {
|
|
2701
|
-
runAIFlag: req.body
|
|
2780
|
+
runAIFlag: req.body?.runAICount ?? 0,
|
|
2702
2781
|
};
|
|
2703
2782
|
|
|
2704
2783
|
// complianceCount is already populated for the other answer types; add the image/video scores on top.
|
|
@@ -3601,7 +3680,7 @@ export async function runAIFlag( req, res ) {
|
|
|
3601
3680
|
runAIFlag: store?.runAIFlag,
|
|
3602
3681
|
questionFlag: store?.questionFlag,
|
|
3603
3682
|
checklistName: store.checkListName?.trim(),
|
|
3604
|
-
submittedBy: store?.userName,
|
|
3683
|
+
submittedBy: store.timeFlag ? '--' : store?.userName,
|
|
3605
3684
|
time: store?.submitTime_string ?? '--',
|
|
3606
3685
|
domain: `${JSON.parse( process.env.URL ).domain}/manage/trax/flags?date=${dayjs().format( 'YYYY-MM-DD' )}`,
|
|
3607
3686
|
status: store.checklistStatus,
|
|
@@ -4044,7 +4044,7 @@ async function insertPCBulkV4( getCLconfig, checklistId, currentdate, updatedche
|
|
|
4044
4044
|
}, { userId: 1, store_id: 1 } );
|
|
4045
4045
|
|
|
4046
4046
|
if ( inprogressData.length ) {
|
|
4047
|
-
await processedchecklist.updateMany( { _id: { $in: inprogressData.map( ( ele ) => new ObjectId( ele._id ) ) } }, { scheduleEndTime: getCLconfig.scheduleEndTime, scheduleEndTime_iso: endTimeIso.format() } );
|
|
4047
|
+
await processedchecklist.updateMany( { _id: { $in: inprogressData.map( ( ele ) => new ObjectId( ele._id ) ) } }, { scheduleEndTime: getCLconfig.scheduleEndTime, scheduleEndTime_iso: endTimeIso.format(), complianceCount: getCLconfig.complianceCount, allowedOverTime: getCLconfig.allowedOverTime, allowedStoreLocation: getCLconfig.allowedStoreLocation, userVerification: getCLconfig.userVerification } );
|
|
4048
4048
|
inprogressData.forEach( ( item ) => {
|
|
4049
4049
|
let checkData = assignUserList.find( ( ele ) => ele.userId.toString() == item.userId.toString() && ele.store_id == item.store_id );
|
|
4050
4050
|
if ( !checkData ) {
|
|
@@ -428,14 +428,19 @@ export const checklistPerformance = async ( req, res ) => {
|
|
|
428
428
|
$cond: [
|
|
429
429
|
{ $gt: [ '$$divisor', 0 ] },
|
|
430
430
|
{
|
|
431
|
-
$
|
|
431
|
+
$max: [
|
|
432
|
+
0,
|
|
432
433
|
{
|
|
433
|
-
$
|
|
434
|
-
{
|
|
435
|
-
|
|
434
|
+
$round: [
|
|
435
|
+
{
|
|
436
|
+
$multiply: [
|
|
437
|
+
{ $divide: [ '$userComplianceCountTotal', '$$divisor' ] },
|
|
438
|
+
100,
|
|
439
|
+
],
|
|
440
|
+
},
|
|
441
|
+
0,
|
|
436
442
|
],
|
|
437
443
|
},
|
|
438
|
-
0,
|
|
439
444
|
],
|
|
440
445
|
},
|
|
441
446
|
0,
|
|
@@ -463,7 +468,7 @@ export const checklistPerformance = async ( req, res ) => {
|
|
|
463
468
|
},
|
|
464
469
|
} );
|
|
465
470
|
let getChecklistPerformanceData = await processedchecklistService.aggregate( findQuery );
|
|
466
|
-
if ( !getChecklistPerformanceData[0].
|
|
471
|
+
if ( !getChecklistPerformanceData[0].count[0].total ) {
|
|
467
472
|
return res.sendError( 'no data found', 204 );
|
|
468
473
|
}
|
|
469
474
|
|
|
@@ -56,14 +56,16 @@
|
|
|
56
56
|
.dp-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:12px;border-bottom:2px solid #00AEEF}
|
|
57
57
|
.dp-header h2{font-size:18px;font-weight:700;color:#1a1a2e}
|
|
58
58
|
.dp-score{font-size:15px;font-weight:700;color:#000000}
|
|
59
|
-
.q-row{
|
|
60
|
-
.q-num{font-size:12px;font-weight:700;color:#000
|
|
61
|
-
.q-body{flex:1}
|
|
59
|
+
.q-row{position:relative;margin-bottom:14px;padding:12px 12px 12px 40px;border-radius:8px;background:transparent}
|
|
60
|
+
.q-num{position:absolute;left:12px;top:12px;font-size:12px;font-weight:700;color:#000}
|
|
62
61
|
.q-text{font-size:13px;color:#444;margin-bottom:4px}
|
|
63
62
|
.q-ans{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:2px 8px;border-radius:12px}
|
|
64
63
|
.ans-yes{background:#e1f5ee;color:#0f6e56}
|
|
65
64
|
.ans-no{background:#fcebeb;color:#a32d2d}
|
|
66
65
|
.flag-badge{background:#faeeda;color:#854f0b;font-size:10px;font-weight:600;padding:1px 6px;border-radius:8px;margin-left:6px}
|
|
66
|
+
.det-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 8px;border-radius:10px;white-space:nowrap}
|
|
67
|
+
.det-matched{background:#e1f5ee;color:#0f6e56}
|
|
68
|
+
.det-notmatched{background:#fcebeb;color:#a32d2d}
|
|
67
69
|
.q-answer-list{margin-top:6px}
|
|
68
70
|
.q-answer-item{margin-top:6px;padding:0;background:transparent;border:none;border-radius:0}
|
|
69
71
|
.q-answer-text{font-size:12px;color:#1a1a1a;line-height:1.5}
|
|
@@ -72,10 +74,11 @@
|
|
|
72
74
|
.q-answer-media img,.q-answer-media video,.q-answer-item td img{display:block;width:200px;height:180px;object-fit:cover;border-radius:6px;margin-bottom:6px}
|
|
73
75
|
.img-grid{display:flex;flex-wrap:wrap;gap:8px}
|
|
74
76
|
.img-grid img{margin-bottom:0}
|
|
75
|
-
.answer-media-grid{
|
|
76
|
-
.answer-media-cell{width:calc(50% - 6px)}
|
|
77
|
+
.answer-media-grid{margin-top:8px;font-size:0}
|
|
78
|
+
.answer-media-cell{display:inline-block;width:calc(50% - 6px);vertical-align:top;margin-bottom:12px;break-inside:avoid}
|
|
79
|
+
.answer-media-cell:nth-child(2n+1){margin-right:12px}
|
|
77
80
|
.answer-media-cell .q-answer-caption{margin-bottom:4px}
|
|
78
|
-
.answer-media-cell img{display:block;width:100%;height:200px;object-fit:
|
|
81
|
+
.answer-media-cell img{display:block;width:100%;height:200px;object-fit:fill;border-radius:6px;margin-bottom:0}
|
|
79
82
|
.q-answer-link{font-size:12px;color:#0085D2;text-decoration:underline;word-break:break-all}
|
|
80
83
|
.q-answer-caption{font-size:11px;color:#666;margin-bottom:4px}
|
|
81
84
|
.q-answer-remarks{font-size:11px;color:#666;margin-top:6px;white-space:pre-line}
|
|
@@ -86,7 +89,6 @@
|
|
|
86
89
|
.user-verify-name{font-size:14px;color:#1a1a1a;font-weight:600}
|
|
87
90
|
.user-verify-sign-label{font-size:12px;color:#666;margin-top:16px;margin-bottom:6px;font-weight:600}
|
|
88
91
|
.user-verify-signature{display:block;width:220px;height:auto;max-height:110px;object-fit:contain;border:1px solid #eee;border-radius:6px;padding:6px;background:#fff}
|
|
89
|
-
.user-verify-signature-text{display:inline-block;min-width:180px;font-size:15px;font-weight:600;color:#1a1a1a;padding-bottom:6px;border-bottom:1px solid #d9d9d9;word-break:break-word}
|
|
90
92
|
/* Footer */
|
|
91
93
|
.page-footer{position:absolute;bottom:20px;left:40px;right:40px;display:flex;justify-content:space-between;align-items:center;font-size:11px;color:#999;border-top:1px solid #d9d9d9;padding-top:10px}
|
|
92
94
|
.footer-brand{display:flex;align-items:center;gap:8px;font-weight:600;color:#0066CC;font-size:11px}
|
|
@@ -124,12 +126,8 @@
|
|
|
124
126
|
|
|
125
127
|
<div class="cover-summary">
|
|
126
128
|
<div class="cover-sum-row"><span class="cover-sum-label">No. of questions</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{numQuestions}}</span></div>
|
|
127
|
-
{{
|
|
128
|
-
<div class="cover-sum-row"><span class="cover-sum-label">
|
|
129
|
-
{{/if}}
|
|
130
|
-
{{#if showRunAIFlag}}
|
|
131
|
-
<div class="cover-sum-row"><span class="cover-sum-label">Run AI flags</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{runAIFlag}}</span></div>
|
|
132
|
-
{{/if}}
|
|
129
|
+
<div class="cover-sum-row"><span class="cover-sum-label">No. of flags</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{numFlags}}</span></div>
|
|
130
|
+
{{!-- <div class="cover-sum-row"><span class="cover-sum-label">AI Breached</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{aiBreached}}</span></div> --}}
|
|
133
131
|
<div class="cover-sum-row"><span class="cover-sum-label">Submitted By</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{submittedBy}}</span></div>
|
|
134
132
|
<div class="cover-sum-row"><span class="cover-sum-label">Country</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{country}}</span></div>
|
|
135
133
|
</div>
|
|
@@ -191,10 +189,10 @@
|
|
|
191
189
|
<div class="q-row">
|
|
192
190
|
<span class="q-num">{{this.qno}}</span>
|
|
193
191
|
<div class="q-body">
|
|
194
|
-
<div class="q-text" style="display:flex;justify-content:space-between">
|
|
192
|
+
<div class="q-text" style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
|
195
193
|
<div>{{this.qname}}</div>
|
|
196
194
|
{{#if this.compliance}}
|
|
197
|
-
<div>Score:{{this.score}}</div>
|
|
195
|
+
<div style="flex-shrink:0">Score:{{this.score}}</div>
|
|
198
196
|
{{/if}}
|
|
199
197
|
</div>
|
|
200
198
|
{{#if this.questionReferenceImage}}
|
|
@@ -220,7 +218,7 @@
|
|
|
220
218
|
<div class="q-answer-item">
|
|
221
219
|
{{#eq this.answerType 'text'}}
|
|
222
220
|
{{#if this.answer}}
|
|
223
|
-
<div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">{{this.answer}}</div>
|
|
221
|
+
<div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">{{this.answer}}{{#if this.showDetectionStatus}} <span class="det-badge {{#eq this.detectionStatus 'Matched'}}det-matched{{else}}det-notmatched{{/eq}}">{{this.detectionStatus}}</span>{{/if}}</div>
|
|
224
222
|
{{/if}}
|
|
225
223
|
{{/eq}}
|
|
226
224
|
{{#eq this.answerType 'video'}}
|
|
@@ -231,6 +229,20 @@
|
|
|
231
229
|
</div>
|
|
232
230
|
{{/if}}
|
|
233
231
|
{{/eq}}
|
|
232
|
+
{{!-- This answer's own images (reference / uploaded / validation), grouped with its label, two per row --}}
|
|
233
|
+
{{#if this.answerMedia.length}}
|
|
234
|
+
<div class="answer-media-grid">
|
|
235
|
+
{{#each this.answerMedia}}
|
|
236
|
+
<div class="answer-media-cell">
|
|
237
|
+
<div class="q-answer-caption" style="display:flex;justify-content:space-between;align-items:center;gap:6px">
|
|
238
|
+
<span>{{this.label}}</span>
|
|
239
|
+
{{#if this.detectionStatus}}<span class="det-badge {{#eq this.detectionStatus 'Matched'}}det-matched{{else}}det-notmatched{{/eq}}">{{this.detectionStatus}}</span>{{/if}}
|
|
240
|
+
</div>
|
|
241
|
+
<img src="{{this.url}}" alt="{{this.label}}" />
|
|
242
|
+
</div>
|
|
243
|
+
{{/each}}
|
|
244
|
+
</div>
|
|
245
|
+
{{/if}}
|
|
234
246
|
{{#eq this.validationDisplayType 'text'}}
|
|
235
247
|
{{#if this.validationAnswer}}
|
|
236
248
|
<div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">validation Answer: {{this.validationAnswer}}</div>
|
|
@@ -243,6 +255,7 @@
|
|
|
243
255
|
{{/if}}
|
|
244
256
|
{{/eq}}
|
|
245
257
|
|
|
258
|
+
{{!-- Validation videos captured with multi-image validation (shown as links) --}}
|
|
246
259
|
{{#eq this.validationDisplayType 'multiImage'}}
|
|
247
260
|
{{#if this.validationVideo.length}}
|
|
248
261
|
<div class="q-answer-media">
|
|
@@ -253,20 +266,25 @@
|
|
|
253
266
|
</div>
|
|
254
267
|
{{/if}}
|
|
255
268
|
{{/eq}}
|
|
269
|
+
|
|
256
270
|
</div>
|
|
257
271
|
{{/each}}
|
|
272
|
+
</div>
|
|
273
|
+
{{/if}}
|
|
274
|
+
{{!-- All images for this question (reference / uploaded / validation), two per row --}}
|
|
258
275
|
{{#if this.mediaItems.length}}
|
|
259
276
|
<div class="answer-media-grid">
|
|
260
277
|
{{#each this.mediaItems}}
|
|
261
278
|
<div class="answer-media-cell">
|
|
262
|
-
<div class="q-answer-caption">
|
|
279
|
+
<div class="q-answer-caption" style="display:flex;justify-content:space-between;align-items:center;gap:6px">
|
|
280
|
+
<span>{{this.label}}</span>
|
|
281
|
+
{{#if this.detectionStatus}}<span class="det-badge {{#eq this.detectionStatus 'Matched'}}det-matched{{else}}det-notmatched{{/eq}}">{{this.detectionStatus}}</span>{{/if}}
|
|
282
|
+
</div>
|
|
263
283
|
<img src="{{this.url}}" alt="{{this.label}}" />
|
|
264
284
|
</div>
|
|
265
285
|
{{/each}}
|
|
266
286
|
</div>
|
|
267
287
|
{{/if}}
|
|
268
|
-
</div>
|
|
269
|
-
{{/if}}
|
|
270
288
|
{{#if this.remarks}}
|
|
271
289
|
<div class="q-answer-remarks">Remarks: {{this.remarks}}</div>
|
|
272
290
|
{{/if}}
|
|
@@ -281,8 +299,8 @@
|
|
|
281
299
|
{{#if userImage}}
|
|
282
300
|
<img class="user-verify-photo" src="{{userImage}}" alt="Submitted by photo" />
|
|
283
301
|
{{/if}}
|
|
284
|
-
{{#if
|
|
285
|
-
<div class="user-verify-
|
|
302
|
+
{{#if submittedBy}}
|
|
303
|
+
<div class="user-verify-name">{{userSignature}}</div>
|
|
286
304
|
{{/if}}
|
|
287
305
|
</div>
|
|
288
306
|
{{/if}}
|
|
@@ -13,6 +13,7 @@ internalTraxRouter
|
|
|
13
13
|
.get( '/list', isAllowedInternalAPIHandler, internalController.list )
|
|
14
14
|
.post( '/updateAomDetails', isAllowedInternalAPIHandler, internalController.aomupdateCollection )
|
|
15
15
|
.post( '/updateSaleManagerDetails', isAllowedInternalAPIHandler, internalController.saleUpdateCollection )
|
|
16
|
+
.post( '/updateValidationType', isAllowedInternalAPIHandler, internalController.updateMultipleChoiceSingleValidationType )
|
|
16
17
|
.get( '/profile', isAllowedMobileSessionHandler, internalController.getUserStoreList )
|
|
17
18
|
.post( '/pushNotification', isAllowedInternalAPIHandler, internalController.pushNotification )
|
|
18
19
|
.post( '/taskPushNotification', isAllowedInternalAPIHandler, internalController.taskPushNotification )
|
|
@@ -146,6 +146,54 @@ function flattenImageRefs( arr ) {
|
|
|
146
146
|
.filter( Boolean );
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Builds the ordered media list (reference → uploaded → validation) for a SINGLE
|
|
151
|
+
* answer entry, deduped within that entry. Used to render each answer's images
|
|
152
|
+
* inline next to its own text/label, instead of pooling every answer's images
|
|
153
|
+
* into one question-level grid (which decoupled labels from their images).
|
|
154
|
+
* @param {Object} ua a single entry produced by buildQuestionAnswerEntries
|
|
155
|
+
* @return {Array} list of { type, label, url, detectionStatus } for this entry, in render order
|
|
156
|
+
*/
|
|
157
|
+
function buildEntryMediaItems( ua = {} ) {
|
|
158
|
+
const items = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
|
|
161
|
+
const push = ( type, label, url, detectionStatus = '' ) => {
|
|
162
|
+
if ( !url || typeof url !== 'string' ) return;
|
|
163
|
+
if ( seen.has( url ) ) return;
|
|
164
|
+
seen.add( url );
|
|
165
|
+
items.push( { type, label, url, detectionStatus } );
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Each uploaded/validation image shows its OWN runAI verdict (per-image, never
|
|
169
|
+
// aggregated). References are not AI-checked, so they carry no badge.
|
|
170
|
+
const statusByUrl = buildAnswerImageStatus( ua );
|
|
171
|
+
const statusFor = ( url ) => statusByUrl.get( url ) || '';
|
|
172
|
+
|
|
173
|
+
if ( ua.hasReferenceImage ) {
|
|
174
|
+
if ( Array.isArray( ua.multiReferenceImage ) && ua.multiReferenceImage.length ) {
|
|
175
|
+
ua.multiReferenceImage.forEach( ( url ) => push( 'reference', 'Reference Image', url ) );
|
|
176
|
+
} else if ( ua.referenceImage ) {
|
|
177
|
+
push( 'reference', 'Reference Image', ua.referenceImage );
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if ( ua.answerType === 'image' && ua.answer ) {
|
|
182
|
+
push( 'uploaded', 'Uploaded Image', ua.answer, statusFor( ua.answer ) );
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if ( ua.validation ) {
|
|
186
|
+
if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) {
|
|
187
|
+
push( 'validation', 'Validation Image', ua.validationAnswer, statusFor( ua.validationAnswer ) );
|
|
188
|
+
}
|
|
189
|
+
if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
|
|
190
|
+
ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url, statusFor( url ) ) );
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return items;
|
|
195
|
+
}
|
|
196
|
+
|
|
149
197
|
function buildQuestionAnswerEntries( question ) {
|
|
150
198
|
const rawUserAnswers = getSourceUserAnswers( question );
|
|
151
199
|
const uniqueUserAnswers = [];
|
|
@@ -190,7 +238,21 @@ function buildQuestionAnswerEntries( question ) {
|
|
|
190
238
|
question?.answerType !== 'multipleImage' &&
|
|
191
239
|
Boolean( referenceImage || multiReferenceImage.length );
|
|
192
240
|
|
|
193
|
-
|
|
241
|
+
// runAI verdicts for this answer, in source order. Used to tag each uploaded /
|
|
242
|
+
// validation image with its OWN verdict (by answerImage filename, else by
|
|
243
|
+
// position) so multi-image answers don't all collapse to one aggregated status.
|
|
244
|
+
const isPerAnswerType = PER_ANSWER_DETECTION_TYPES.includes( question?.answerType );
|
|
245
|
+
const runAIValues = getRunAIMatchValues( userAnswer?.runAIData );
|
|
246
|
+
const imageStatus = buildImageStatusMap( userAnswer?.runAIData );
|
|
247
|
+
|
|
248
|
+
// Choice answers (yes/no, multiple choice, dropdown) take this answer's own
|
|
249
|
+
// verdict directly — never aggregated. Other types keep the aggregate only as
|
|
250
|
+
// a last-resort fallback.
|
|
251
|
+
const detectionStatus = isPerAnswerType ?
|
|
252
|
+
verdictFromValue( runAIValues[0] ) :
|
|
253
|
+
getDetectionStatus( userAnswer?.runAIData );
|
|
254
|
+
|
|
255
|
+
const entry = {
|
|
194
256
|
answer: userAnswer?.answer || '',
|
|
195
257
|
answerType: getMediaDisplayType( question?.answerType, userAnswer ),
|
|
196
258
|
referenceImage,
|
|
@@ -198,6 +260,9 @@ function buildQuestionAnswerEntries( question ) {
|
|
|
198
260
|
multiReferenceImage,
|
|
199
261
|
remarks: userAnswer?.remarks || '',
|
|
200
262
|
sopFlag: userAnswer?.sopFlag ?? matchedAnswer?.sopFlag ?? false,
|
|
263
|
+
detectionStatus,
|
|
264
|
+
imageStatus,
|
|
265
|
+
runAIValues,
|
|
201
266
|
validation,
|
|
202
267
|
validationType,
|
|
203
268
|
validationAnswer,
|
|
@@ -205,6 +270,19 @@ function buildQuestionAnswerEntries( question ) {
|
|
|
205
270
|
validationVideo,
|
|
206
271
|
validationDisplayType: getValidationDisplayType( validationType ),
|
|
207
272
|
};
|
|
273
|
+
|
|
274
|
+
// Text-label answers (e.g. "Attach the image of Cat") carry their own
|
|
275
|
+
// reference/validation images. Group those images with this entry so the
|
|
276
|
+
// label and its images render together, instead of pooling every answer's
|
|
277
|
+
// images into one question-level grid. Image/video answers keep flowing
|
|
278
|
+
// into the pooled grid (handled by buildQuestionMediaItems).
|
|
279
|
+
entry.answerMedia = entry.answerType === 'text' ? buildEntryMediaItems( entry ) : [];
|
|
280
|
+
|
|
281
|
+
// Choice answers with no media show their verdict next to the answer text;
|
|
282
|
+
// when they carry validation media the badge sits on that media instead.
|
|
283
|
+
entry.showDetectionStatus = isPerAnswerType && Boolean( detectionStatus ) && !entry.answerMedia.length;
|
|
284
|
+
|
|
285
|
+
return entry;
|
|
208
286
|
} );
|
|
209
287
|
}
|
|
210
288
|
|
|
@@ -216,21 +294,38 @@ function buildQuestionAnswerEntries( question ) {
|
|
|
216
294
|
* collected here. When a question has no reference image, the uploaded images simply
|
|
217
295
|
* fill the grid from the start (taking the reference slot).
|
|
218
296
|
* @param {Array} userAnswers entries produced by buildQuestionAnswerEntries
|
|
219
|
-
* @
|
|
297
|
+
* @param {string} [forcedStatus] when set (image / image-video), every uploaded &
|
|
298
|
+
* validation image shows this aggregated verdict instead of its own per-image one
|
|
299
|
+
* @return {Array} list of { type, label, url, detectionStatus } media items in render order
|
|
220
300
|
*/
|
|
221
|
-
function buildQuestionMediaItems( userAnswers = [] ) {
|
|
301
|
+
function buildQuestionMediaItems( userAnswers = [], forcedStatus = '' ) {
|
|
222
302
|
const items = [];
|
|
223
303
|
const seen = new Set();
|
|
224
304
|
|
|
225
|
-
const push = ( type, label, url ) => {
|
|
305
|
+
const push = ( type, label, url, detectionStatus = '' ) => {
|
|
226
306
|
if ( !url || typeof url !== 'string' ) return;
|
|
227
307
|
if ( seen.has( url ) ) return;
|
|
228
308
|
seen.add( url );
|
|
229
|
-
items.push( { type, label, url } );
|
|
309
|
+
items.push( { type, label, url, detectionStatus } );
|
|
230
310
|
};
|
|
231
311
|
|
|
312
|
+
// Entries that render their media inline with the answer (answerMedia) are
|
|
313
|
+
// excluded here so their images are not duplicated in the pooled grid.
|
|
314
|
+
const pool = userAnswers.filter( ( ua ) => !( ua.answerMedia && ua.answerMedia.length ) );
|
|
315
|
+
|
|
316
|
+
// Resolve each image's status per answer (per-image; forcedStatus aggregates
|
|
317
|
+
// image / image-video). Built per answer so positional matching stays aligned.
|
|
318
|
+
const statusByUrl = new Map();
|
|
319
|
+
pool.forEach( ( ua ) => {
|
|
320
|
+
buildAnswerImageStatus( ua, forcedStatus ).forEach( ( status, url ) => statusByUrl.set( url, status ) );
|
|
321
|
+
} );
|
|
322
|
+
const statusFor = ( url ) => statusByUrl.get( url ) || '';
|
|
323
|
+
|
|
232
324
|
// 1. Reference images first (deduped, so a single shared reference shows once).
|
|
233
|
-
|
|
325
|
+
// Skipped for image/video & multipleImage upload questions (hasReferenceImage
|
|
326
|
+
// is false), which have no comparison reference — only uploaded answers.
|
|
327
|
+
pool.forEach( ( ua ) => {
|
|
328
|
+
if ( !ua.hasReferenceImage ) return;
|
|
234
329
|
if ( Array.isArray( ua.multiReferenceImage ) && ua.multiReferenceImage.length ) {
|
|
235
330
|
ua.multiReferenceImage.forEach( ( url ) => push( 'reference', 'Reference Image', url ) );
|
|
236
331
|
} else if ( ua.referenceImage ) {
|
|
@@ -238,23 +333,23 @@ function buildQuestionMediaItems( userAnswers = [] ) {
|
|
|
238
333
|
}
|
|
239
334
|
} );
|
|
240
335
|
|
|
241
|
-
// 2. Uploaded images.
|
|
242
|
-
|
|
336
|
+
// 2. Uploaded images — tagged with each image's own detection status (from runAIData).
|
|
337
|
+
pool.forEach( ( ua ) => {
|
|
243
338
|
if ( ua.answerType === 'image' && ua.answer ) {
|
|
244
|
-
push( 'uploaded', 'Uploaded Image', ua.answer );
|
|
339
|
+
push( 'uploaded', 'Uploaded Image', ua.answer, statusFor( ua.answer ) );
|
|
245
340
|
}
|
|
246
341
|
} );
|
|
247
342
|
|
|
248
343
|
// 3. Validation images (single 'Capture Image' and multi 'Capture Multiple Image').
|
|
249
|
-
|
|
344
|
+
pool.forEach( ( ua ) => {
|
|
250
345
|
if ( !ua.validation ) return;
|
|
251
346
|
|
|
252
347
|
if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) {
|
|
253
|
-
push( 'validation', 'Validation Image', ua.validationAnswer );
|
|
348
|
+
push( 'validation', 'Validation Image', ua.validationAnswer, statusFor( ua.validationAnswer ) );
|
|
254
349
|
}
|
|
255
350
|
|
|
256
351
|
if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
|
|
257
|
-
ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url ) );
|
|
352
|
+
ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url, statusFor( url ) ) );
|
|
258
353
|
}
|
|
259
354
|
} );
|
|
260
355
|
|
|
@@ -289,6 +384,125 @@ function getRunAIMatchValues( runAIData ) {
|
|
|
289
384
|
}
|
|
290
385
|
|
|
291
386
|
|
|
387
|
+
// Answer types whose runAI verdict is aggregated across all uploaded images:
|
|
388
|
+
// if any image is not matched, the whole question reads 'Not Matched'. Every
|
|
389
|
+
// other type shows each image's own verdict.
|
|
390
|
+
const AGGREGATE_DETECTION_TYPES = [ 'image', 'image/video' ];
|
|
391
|
+
|
|
392
|
+
// Choice answers: each selected answer carries its OWN runAI verdict and shows it
|
|
393
|
+
// directly — never aggregated ('.every') across the question's other answers.
|
|
394
|
+
const PER_ANSWER_DETECTION_TYPES = [ 'yes/no', 'multiplechoicesingle', 'multiplechoicemultiple', 'dropdown' ];
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Maps a single runAI value to a display status.
|
|
399
|
+
* @param {string|boolean} value
|
|
400
|
+
* @return {string} 'Matched' | 'Not Matched' | ''
|
|
401
|
+
*/
|
|
402
|
+
function verdictFromValue( value ) {
|
|
403
|
+
if ( value === 'True' || value === true ) return 'Matched';
|
|
404
|
+
if ( value === 'False' || value === false ) return 'Not Matched';
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The filename of an image URL/path, ignoring any query string. Used to match an
|
|
411
|
+
* uploaded/validation image against its runAIData entry (whose answerImage may
|
|
412
|
+
* carry a different prefix / signed-URL query), regardless of CDN resolution.
|
|
413
|
+
* @param {string} url
|
|
414
|
+
* @return {string}
|
|
415
|
+
*/
|
|
416
|
+
function imageBasename( url ) {
|
|
417
|
+
return String( url || '' ).split( '?' )[0].split( '/' ).pop();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Builds a per-image detection status map from the nested runAIData shape
|
|
423
|
+
* ([ { answerImage, results: [ { featureName, value } ] } ]), keyed by the
|
|
424
|
+
* answerImage's filename. The flat shape has no per-image key, so it yields an
|
|
425
|
+
* empty map and callers fall back to the answer-level status.
|
|
426
|
+
* @param {Array} runAIData
|
|
427
|
+
* @return {Object} { [filename]: 'Matched' | 'Not Matched' }
|
|
428
|
+
*/
|
|
429
|
+
function buildImageStatusMap( runAIData ) {
|
|
430
|
+
const map = {};
|
|
431
|
+
if ( !Array.isArray( runAIData ) ) return map;
|
|
432
|
+
|
|
433
|
+
runAIData.forEach( ( item ) => {
|
|
434
|
+
if ( !item || !item.answerImage ) return;
|
|
435
|
+
const results = Array.isArray( item.results ) ? item.results : [ item ];
|
|
436
|
+
const values = results
|
|
437
|
+
.filter( ( r ) => r?.featureName === 'Matched/Not Matched' )
|
|
438
|
+
.map( ( r ) => r.value );
|
|
439
|
+
if ( !values.length ) return;
|
|
440
|
+
const matched = values.every( ( v ) => v === 'True' || v === true );
|
|
441
|
+
map[imageBasename( item.answerImage )] = matched ? 'Matched' : 'Not Matched';
|
|
442
|
+
} );
|
|
443
|
+
|
|
444
|
+
return map;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Resolves the detection status for each checked image (uploaded + validation) of a
|
|
450
|
+
* single answer, returned as a Map of url → 'Matched' | 'Not Matched' | ''.
|
|
451
|
+
*
|
|
452
|
+
* Each image gets its OWN verdict — never aggregated across the answer's other images:
|
|
453
|
+
* 1. matched explicitly by its answerImage filename (nested runAIData), else
|
|
454
|
+
* 2. matched positionally — the Nth checked image takes the Nth runAI verdict
|
|
455
|
+
* (works for the flat runAIData shape and when filenames don't line up).
|
|
456
|
+
* For image / image-video, forcedStatus (the question-level aggregate) overrides all.
|
|
457
|
+
* @param {Object} ua a single entry produced by buildQuestionAnswerEntries
|
|
458
|
+
* @param {string} [forcedStatus] aggregated status applied to every image (image/image-video)
|
|
459
|
+
* @return {Map<string,string>} url → status
|
|
460
|
+
*/
|
|
461
|
+
function buildAnswerImageStatus( ua = {}, forcedStatus = '' ) {
|
|
462
|
+
const statusByUrl = new Map();
|
|
463
|
+
const byName = ua.imageStatus || {};
|
|
464
|
+
const values = Array.isArray( ua.runAIValues ) ? ua.runAIValues : [];
|
|
465
|
+
let idx = 0;
|
|
466
|
+
|
|
467
|
+
const assign = ( url ) => {
|
|
468
|
+
if ( !url || typeof url !== 'string' || statusByUrl.has( url ) ) return;
|
|
469
|
+
let status = forcedStatus;
|
|
470
|
+
if ( !status ) {
|
|
471
|
+
const key = imageBasename( url );
|
|
472
|
+
status = ( key in byName ) ? byName[key] : verdictFromValue( values[idx] );
|
|
473
|
+
idx++;
|
|
474
|
+
}
|
|
475
|
+
statusByUrl.set( url, status );
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if ( ua.answerType === 'image' && ua.answer ) assign( ua.answer );
|
|
479
|
+
|
|
480
|
+
if ( ua.validation ) {
|
|
481
|
+
if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) assign( ua.validationAnswer );
|
|
482
|
+
if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
|
|
483
|
+
ua.validationImage.forEach( assign );
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return statusByUrl;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Resolves a 'Matched' / 'Not Matched' detection status from runAIData. Handles both
|
|
493
|
+
* runAIData shapes (per-image nested results, and flat). Any non-match verdict makes
|
|
494
|
+
* the whole status 'Not Matched'. Returns '' when there is no runAI verdict to report.
|
|
495
|
+
* @param {Array} runAIData
|
|
496
|
+
* @return {string} 'Matched' | 'Not Matched' | ''
|
|
497
|
+
*/
|
|
498
|
+
function getDetectionStatus( runAIData ) {
|
|
499
|
+
const values = getRunAIMatchValues( runAIData );
|
|
500
|
+
if ( !values.length ) return '';
|
|
501
|
+
const allMatched = values.every( ( v ) => v === 'True' || v === true );
|
|
502
|
+
return allMatched ? 'Matched' : 'Not Matched';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
292
506
|
/**
|
|
293
507
|
|
|
294
508
|
* Map section array { sectionName, questions[] } (view API or processed checklist) to scores / template sections.
|
|
@@ -360,6 +574,22 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
|
|
|
360
574
|
}
|
|
361
575
|
|
|
362
576
|
|
|
577
|
+
// image / image-video aggregate their runAI verdict across all uploaded images
|
|
578
|
+
// (any 'Not Matched' → whole question 'Not Matched'). Every other type keeps
|
|
579
|
+
// each image's own verdict, so this stays '' for them.
|
|
580
|
+
let aggregateDetectionStatus = '';
|
|
581
|
+
if ( AGGREGATE_DETECTION_TYPES.includes( q.answerType ) ) {
|
|
582
|
+
const aiAnswers = q.answerType === 'image/video' ?
|
|
583
|
+
( q.userAnswer || [] ).filter( ( a ) => a?.answerType === 'image' ) :
|
|
584
|
+
( q.userAnswer || [] );
|
|
585
|
+
const matchValues = aiAnswers.flatMap( ( a ) => getRunAIMatchValues( a?.runAIData ) );
|
|
586
|
+
if ( matchValues.length ) {
|
|
587
|
+
aggregateDetectionStatus = matchValues.every( ( v ) => v === 'True' || v === true ) ?
|
|
588
|
+
'Matched' : 'Not Matched';
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
|
|
363
593
|
sectionScore += score;
|
|
364
594
|
|
|
365
595
|
sectionMax += max;
|
|
@@ -417,7 +647,7 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
|
|
|
417
647
|
|
|
418
648
|
answerDisplay: isYes ? 'Yes' : ( isNo ? 'No' : ( answerText && answerText.startsWith( 'http' ) ? 'Image' : ( answerText || '—' ) ) ),
|
|
419
649
|
|
|
420
|
-
mediaItems: buildQuestionMediaItems( userAnswersWithRef ),
|
|
650
|
+
mediaItems: buildQuestionMediaItems( userAnswersWithRef, aggregateDetectionStatus ),
|
|
421
651
|
|
|
422
652
|
userAnswer: userAnswersWithRef.length ? userAnswersWithRef : [ {
|
|
423
653
|
answer: '',
|
|
@@ -442,15 +672,15 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
|
|
|
442
672
|
|
|
443
673
|
sectionName: section.sectionName || `Section ${sectionIdx + 1}`,
|
|
444
674
|
|
|
445
|
-
targetScore: sectionMax,
|
|
675
|
+
targetScore: sectionMax ? sectionMax : '--',
|
|
446
676
|
|
|
447
|
-
actualScore: sectionScore,
|
|
677
|
+
actualScore: sectionMax ? sectionScore : '--',
|
|
448
678
|
|
|
449
679
|
questionsCount,
|
|
450
680
|
|
|
451
681
|
passedCount,
|
|
452
682
|
|
|
453
|
-
percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) :
|
|
683
|
+
percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : '--',
|
|
454
684
|
|
|
455
685
|
} );
|
|
456
686
|
|
|
@@ -939,6 +1169,10 @@ export function createImageCache() {
|
|
|
939
1169
|
if ( typeof u === 'string' && u.startsWith( 'http' ) ) urls.add( u );
|
|
940
1170
|
} );
|
|
941
1171
|
}
|
|
1172
|
+
|
|
1173
|
+
ua.answerMedia?.forEach( ( m ) => {
|
|
1174
|
+
if ( typeof m.url === 'string' && m.url.startsWith( 'http' ) ) urls.add( m.url );
|
|
1175
|
+
} );
|
|
942
1176
|
} );
|
|
943
1177
|
|
|
944
1178
|
q.mediaItems?.forEach( ( m ) => {
|
|
@@ -1006,6 +1240,12 @@ export function createImageCache() {
|
|
|
1006
1240
|
if ( ua.validationDisplayType === 'multiImage' && ua.validationImage?.length ) {
|
|
1007
1241
|
ua.validationImage = ua.validationImage.map( ( u ) => ( cache.has( u ) ? cache.get( u ) : u ) );
|
|
1008
1242
|
}
|
|
1243
|
+
|
|
1244
|
+
if ( ua.answerMedia?.length ) {
|
|
1245
|
+
ua.answerMedia.forEach( ( m ) => {
|
|
1246
|
+
if ( cache.has( m.url ) ) m.url = cache.get( m.url );
|
|
1247
|
+
} );
|
|
1248
|
+
}
|
|
1009
1249
|
} );
|
|
1010
1250
|
|
|
1011
1251
|
if ( q.mediaItems?.length ) {
|
|
@@ -1093,6 +1333,12 @@ export function resolveTemplateUrls( templateData, baseUrl = 'https://d1r0hc2ssk
|
|
|
1093
1333
|
if ( ua.answer && ua.answerType !== 'text' ) {
|
|
1094
1334
|
ua.answer = resolveUrl( ua.answer );
|
|
1095
1335
|
}
|
|
1336
|
+
|
|
1337
|
+
if ( ua.answerMedia?.length ) {
|
|
1338
|
+
ua.answerMedia.forEach( ( m ) => {
|
|
1339
|
+
m.url = resolveUrl( m.url );
|
|
1340
|
+
} );
|
|
1341
|
+
}
|
|
1096
1342
|
} );
|
|
1097
1343
|
|
|
1098
1344
|
if ( q.mediaItems?.length ) {
|
|
@@ -1239,6 +1485,12 @@ export async function generateVisitChecklistPDF( templateData, baseUrl = 'https:
|
|
|
1239
1485
|
if ( ua.answer && ua.answerType !== 'text' ) {
|
|
1240
1486
|
ua.answer = resolveUrl( ua.answer );
|
|
1241
1487
|
}
|
|
1488
|
+
|
|
1489
|
+
if ( ua.answerMedia?.length ) {
|
|
1490
|
+
ua.answerMedia.forEach( ( m ) => {
|
|
1491
|
+
m.url = resolveUrl( m.url );
|
|
1492
|
+
} );
|
|
1493
|
+
}
|
|
1242
1494
|
} );
|
|
1243
1495
|
|
|
1244
1496
|
if ( q.mediaItems?.length ) {
|