pict-section-recordset 1.23.0 → 1.24.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-recordset",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "Pict dynamic record set management views",
5
5
  "main": "source/Pict-Section-RecordSet.js",
6
6
  "files": [
@@ -14,7 +14,7 @@ const _DEFAULT_CONFIGURATION =
14
14
  .psrs-card-trigger-icon { display: inline-flex; font-size: 0.82em; color: var(--theme-color-text-muted, #6b7686); }
15
15
  .psrs-card-trigger:hover .psrs-card-trigger-icon { color: var(--theme-color-brand-primary, #156dd1); }
16
16
 
17
- .psrs-card-popover { position: absolute; z-index: 4000; min-width: 16rem; max-width: 22rem;
17
+ .psrs-card-popover { position: absolute; z-index: 4000; min-width: 16rem; max-width: 22rem; max-height: 70vh; overflow-y: auto;
18
18
  background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #d7dce3);
19
19
  border-radius: 12px; box-shadow: 0 16px 40px rgba(15, 23, 42, 0.20); padding: 0.95rem 1.05rem;
20
20
  opacity: 0; transform: translateY(-4px); transition: opacity 0.12s ease, transform 0.12s ease; pointer-events: none; }
@@ -28,6 +28,10 @@ const _DEFAULT_CONFIGURATION =
28
28
  .psrs-card-fields { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 0.85rem; align-items: baseline; margin: 0.2rem 0 0; }
29
29
  .psrs-card-label { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--theme-color-text-muted, #6b7686); white-space: nowrap; }
30
30
  .psrs-card-value { font-size: 0.9rem; color: var(--theme-color-text-primary, #1f2733); word-break: break-word; }
31
+ .psrs-card-bool { display: inline-flex; align-items: center; justify-content: center; width: 1.05rem; height: 1.05rem; border-radius: 4px;
32
+ border: 1.5px solid var(--theme-color-border-default, #c7cdd6); color: #fff; vertical-align: middle; }
33
+ .psrs-card-bool-on { background: var(--theme-color-brand-primary, #156dd1); border-color: var(--theme-color-brand-primary, #156dd1); }
34
+ .psrs-card-bool .pict-icon { font-size: 0.8em; }
31
35
  .psrs-card-custom { font-size: 0.9rem; color: var(--theme-color-text-primary, #1f2733); }
32
36
  .psrs-card-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.85rem; padding-top: 0.7rem; border-top: 1px solid var(--theme-color-border-light, #eef1f5); }
33
37
  .psrs-card-action { display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; font: inherit; font-size: 0.82rem; font-weight: 600;
@@ -35,6 +39,22 @@ const _DEFAULT_CONFIGURATION =
35
39
  background: var(--theme-color-background-secondary, #f5f6f8); color: var(--theme-color-text-secondary, #45505f); }
36
40
  .psrs-card-action:hover { border-color: var(--theme-color-brand-primary, #156dd1); color: var(--theme-color-brand-primary, #156dd1); background: var(--theme-color-background-selected, #e3edfb); }
37
41
  .psrs-card-action .pict-icon { font-size: 0.9em; }
42
+ /* Audit stripe — a thin Created/Updated line + ID/GUID copy buttons, shown above the actions when on. */
43
+ .psrs-card-audit { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.8rem; margin-top: 0.7rem; padding-top: 0.55rem;
44
+ border-top: 1px dashed var(--theme-color-border-light, #eef1f5); font-size: 0.7rem; line-height: 1.35; color: var(--theme-color-text-muted, #6b7686); }
45
+ .psrs-card-audit-info { display: flex; flex-wrap: wrap; align-items: center; gap: 0.15rem 0.5rem; }
46
+ .psrs-card-audit-info b { font-weight: 600; color: var(--theme-color-text-secondary, #45505f); }
47
+ .psrs-card-audit-sep { opacity: 0.45; }
48
+ .psrs-card-audit-copy { display: inline-flex; gap: 0.3rem; margin-left: auto; }
49
+ .psrs-card-copy { display: inline-flex; align-items: center; gap: 0.2rem; cursor: pointer; font: inherit; font-size: 0.68rem; font-weight: 600;
50
+ border: 1px solid var(--theme-color-border-light, #e1e6ec); border-radius: 6px; padding: 0.08rem 0.4rem;
51
+ background: var(--theme-color-background-secondary, #f5f6f8); color: var(--theme-color-text-muted, #6b7686); }
52
+ .psrs-card-copy:hover { border-color: var(--theme-color-brand-primary, #156dd1); color: var(--theme-color-brand-primary, #156dd1); }
53
+ .psrs-card-copy.psrs-card-copied { border-color: #2e9e5b; color: #2e9e5b; }
54
+ .psrs-card-copy .pict-icon { font-size: 0.85em; }
55
+ .psrs-card-audit-toggle { display: inline-flex; align-items: center; gap: 0.3rem; margin-left: auto; font-size: 0.74rem; font-weight: 600;
56
+ color: var(--theme-color-text-muted, #6b7686); cursor: pointer; white-space: nowrap; }
57
+ .psrs-card-audit-toggle input { cursor: pointer; margin: 0; }
38
58
  `,
39
59
  CSSPriority: 600,
40
60
  };
@@ -97,6 +117,7 @@ class RecordSetCardManager extends libPictProvider
97
117
  {
98
118
  Entity: pCardConfig.Entity || pRecordSetName,
99
119
  IDField: pCardConfig.IDField || `ID${pRecordSetName}`,
120
+ GUIDField: pCardConfig.GUIDField || `GUID${pCardConfig.Entity || pRecordSetName}`,
100
121
  TemplateHash: `PSRS-RecordCard-${pRecordSetName}`,
101
122
  Config: pCardConfig,
102
123
  };
@@ -206,63 +227,402 @@ class RecordSetCardManager extends libPictProvider
206
227
  * @param {String|Number|Object} pIDOrRecord
207
228
  * @param {HTMLElement} pAnchorElement
208
229
  */
209
- openCard(pRecordSetName, pIDOrRecord, pAnchorElement)
230
+ openCard(pRecordSetName, pIDOrRecord, pAnchorElement, pOptions)
210
231
  {
211
- const tmpCard = this._cards[pRecordSetName];
232
+ const tmpOptions = pOptions || {};
233
+ // A registered card, or a synthesized default card so any entity gets a preview (rich callers).
234
+ let tmpCard = this._cards[pRecordSetName];
235
+ if (!tmpCard) { tmpCard = this._defaultCard(pRecordSetName, tmpOptions); }
212
236
  if (!tmpCard) { return; }
213
- // Toggle off if the same trigger is clicked while open.
214
- const tmpKey = `${pRecordSetName}:${(typeof pIDOrRecord === 'object') ? '' : pIDOrRecord}`;
237
+
238
+ const tmpKeyID = (pIDOrRecord && (typeof pIDOrRecord === 'object'))
239
+ ? ((pIDOrRecord[tmpCard.IDField] != null) ? pIDOrRecord[tmpCard.IDField] : '')
240
+ : pIDOrRecord;
241
+ const tmpKey = `${pRecordSetName}:${tmpKeyID}`;
215
242
  if (this._openForKey === tmpKey && this._isOpen())
216
243
  {
217
244
  this.closeCard();
218
245
  return;
219
246
  }
220
247
 
248
+ // The audit stripe + the "more data" body both need the FULL record (the explorer passes only the few
249
+ // Lite columns). Only RICH cards render those, so only rich cards fetch — a normal recordset card keeps
250
+ // its exact existing behavior (no extra fetch) even when the global audit toggle is on.
251
+ const tmpWantFull = !!tmpOptions.Rich;
221
252
  if (pIDOrRecord && (typeof pIDOrRecord === 'object'))
222
253
  {
223
- this._renderAndShow(tmpCard, pIDOrRecord, pAnchorElement, tmpKey);
224
- return;
254
+ const tmpID = pIDOrRecord[tmpCard.IDField];
255
+ if (tmpWantFull && (tmpID != null) && this.pict.EntityProvider)
256
+ {
257
+ return this._fetchAndShow(tmpCard, tmpID, pAnchorElement, tmpKey, tmpOptions, pIDOrRecord);
258
+ }
259
+ return this._renderAndShow(tmpCard, pIDOrRecord, pAnchorElement, tmpKey, tmpOptions);
225
260
  }
226
- this.pict.EntityProvider.getEntity(tmpCard.Entity, pIDOrRecord, (pError, pRecord) =>
261
+ if (!this.pict.EntityProvider) { return; }
262
+ return this._fetchAndShow(tmpCard, pIDOrRecord, pAnchorElement, tmpKey, tmpOptions, null);
263
+ }
264
+
265
+ /** Fetch the full record (and, when the audit stripe is on, its creating/updating user names) then show. */
266
+ _fetchAndShow(pCard, pID, pAnchorElement, pKey, pOptions, pFallbackRecord)
267
+ {
268
+ this.pict.EntityProvider.getEntity(pCard.Entity, pID, (pError, pRecord) =>
227
269
  {
228
- if (pError || !pRecord)
270
+ const tmpRecord = (pError || !pRecord) ? pFallbackRecord : pRecord;
271
+ if (!tmpRecord)
229
272
  {
230
- this.log.warn(`RecordSetCardManager: could not load ${tmpCard.Entity} ${pIDOrRecord} for its preview card.`, pError);
273
+ this.log.warn(`RecordSetCardManager: could not load ${pCard.Entity} ${pID} for its preview card.`, pError);
231
274
  return;
232
275
  }
233
- this._renderAndShow(tmpCard, pRecord, pAnchorElement, tmpKey);
276
+ const fRender = () =>
277
+ {
278
+ if (this.getAuditEnabled())
279
+ {
280
+ return this._resolveAuditUsers(tmpRecord, () => this._renderAndShow(pCard, tmpRecord, pAnchorElement, pKey, pOptions));
281
+ }
282
+ return this._renderAndShow(pCard, tmpRecord, pAnchorElement, pKey, pOptions);
283
+ };
284
+ // Rich cards checkbox-render schema booleans → ensure the entity's (cached) schema first.
285
+ if (pOptions && pOptions.Rich) { return this._ensureSchema(pCard.Entity, fRender); }
286
+ return fRender();
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Resolve + cache the set of BOOLEAN columns for an entity (so rich cards show 0/1 bits as checkboxes).
292
+ * Uses the host-supplied `SchemaSource(entity, cb) -> (err, schema)` — meadow JSON-schema (`properties[].type`)
293
+ * or Stricture (`Columns[].DataType`). No source / fetch failure → no booleans (values render verbatim).
294
+ */
295
+ _ensureSchema(pEntity, fComplete)
296
+ {
297
+ if (!this._schemaBooleans) { this._schemaBooleans = {}; }
298
+ if (this._schemaBooleans[pEntity]) { return fComplete(); }
299
+ if (typeof this.SchemaSource !== 'function') { this._schemaBooleans[pEntity] = {}; return fComplete(); }
300
+ let tmpSettled = false;
301
+ const fDone = (pSchema) =>
302
+ {
303
+ if (tmpSettled) { return; }
304
+ tmpSettled = true;
305
+ const tmpBooleans = {};
306
+ if (pSchema && pSchema.properties)
307
+ {
308
+ Object.keys(pSchema.properties).forEach((pKey) => { if (pSchema.properties[pKey] && (pSchema.properties[pKey].type === 'boolean')) { tmpBooleans[pKey] = true; } });
309
+ }
310
+ else if (pSchema && Array.isArray(pSchema.Columns))
311
+ {
312
+ pSchema.Columns.forEach((pColumn) => { if (pColumn && (pColumn.DataType === 'Boolean')) { tmpBooleans[pColumn.Column] = true; } });
313
+ }
314
+ this._schemaBooleans[pEntity] = tmpBooleans;
315
+ fComplete();
316
+ };
317
+ try { this.SchemaSource(pEntity, (pError, pSchema) => fDone(pError ? null : pSchema)); }
318
+ catch (pError) { fDone(null); }
319
+ }
320
+
321
+ /** Synthesize a card for an entity that has none registered, so every record still gets a preview. */
322
+ _defaultCard(pRecordSetName, pOptions)
323
+ {
324
+ const tmpEntityConfig = (pOptions && pOptions.EntityConfig) || {};
325
+ const tmpEntity = tmpEntityConfig.Entity || pRecordSetName;
326
+ return {
327
+ Entity: tmpEntity,
328
+ IDField: tmpEntityConfig.IDField || `ID${pRecordSetName}`,
329
+ GUIDField: tmpEntityConfig.GUIDField || `GUID${tmpEntity}`,
330
+ TemplateHash: null, // no pre-compiled template — default cards always render the rich (dynamic) body
331
+ Config: { _Default: true, Title: (tmpEntityConfig.Display && tmpEntityConfig.Display.Title) || null, Actions: [] },
332
+ };
333
+ }
334
+
335
+ /** Resolve creating/updating user display names onto the record (cached lookups) before a sync render. */
336
+ _resolveAuditUsers(pRecord, fComplete)
337
+ {
338
+ const tmpEntityProvider = this.pict.EntityProvider;
339
+ if (!tmpEntityProvider || (typeof tmpEntityProvider.getEntity !== 'function')) { return fComplete(); }
340
+ const tmpJobs = [];
341
+ if (pRecord.CreatingIDUser) { tmpJobs.push({ Field: 'CreatingUserName', ID: pRecord.CreatingIDUser }); }
342
+ if (pRecord.UpdatingIDUser) { tmpJobs.push({ Field: 'UpdatingUserName', ID: pRecord.UpdatingIDUser }); }
343
+ if (tmpJobs.length < 1) { return fComplete(); }
344
+ let tmpPending = tmpJobs.length;
345
+ tmpJobs.forEach((pJob) =>
346
+ {
347
+ tmpEntityProvider.getEntity('User', pJob.ID, (pError, pUser) =>
348
+ {
349
+ if (!pError && pUser)
350
+ {
351
+ pRecord[pJob.Field] = [ pUser.NameFirst, pUser.NameLast ].filter((pPart) => (typeof pPart === 'string') && pPart.trim().length > 0).join(' ')
352
+ || pUser.FullName || pUser.Name || pUser.LoginID || `User ${pJob.ID}`;
353
+ }
354
+ tmpPending--;
355
+ if (tmpPending <= 0) { fComplete(); }
356
+ });
234
357
  });
235
358
  }
236
359
 
237
- _renderAndShow(pCard, pRecord, pAnchorElement, pKey)
360
+ _renderAndShow(pCard, pRecord, pAnchorElement, pKey, pOptions)
238
361
  {
239
- // Cards use synchronous template tags ({~D:~}, {~I:~}, formatters) so a sync parse is correct and
240
- // avoids a flash; the record is already in hand here.
362
+ const tmpOptions = pOptions || {};
363
+ // A default card (no pre-compiled template) or a rich caller renders the dynamic body (all columns +
364
+ // audit stripe). Otherwise the existing pre-compiled card template renders verbatim (no behavior change).
365
+ const tmpRich = !!tmpOptions.Rich || !pCard.TemplateHash;
241
366
  let tmpHTML = '';
242
- try { tmpHTML = this.pict.parseTemplateByHash(pCard.TemplateHash, pRecord); }
243
- catch (pError) { this.log.error(`RecordSetCardManager: card render failed for ${pCard.TemplateHash}.`, pError); return; }
367
+ if (!tmpRich)
368
+ {
369
+ // Cards use synchronous template tags ({~D:~}, {~I:~}, formatters) so a sync parse is correct and
370
+ // avoids a flash; the record is already in hand here.
371
+ try { tmpHTML = this.pict.parseTemplateByHash(pCard.TemplateHash, pRecord); }
372
+ catch (pError) { this.log.error(`RecordSetCardManager: card render failed for ${pCard.TemplateHash}.`, pError); return; }
373
+ }
374
+ else
375
+ {
376
+ tmpHTML = this._buildRichCardHTML(pCard, pRecord, tmpOptions);
377
+ }
244
378
  const tmpPopover = this._ensurePopover();
245
379
  if (!tmpPopover) { return; }
246
380
  tmpPopover.innerHTML = tmpHTML;
247
- // Drop any field whose value rendered empty so a card never shows a "LABEL: (blank)" row — the
248
- // configured Fields are static, but which ones have data varies per record.
249
- const tmpValueCells = tmpPopover.querySelectorAll('.psrs-card-fields .psrs-card-value');
250
- for (let i = 0; i < tmpValueCells.length; i++)
381
+ if (!tmpRich)
251
382
  {
252
- if (String(tmpValueCells[i].textContent || '').trim() !== '') { continue; }
253
- const tmpLabelCell = tmpValueCells[i].previousElementSibling;
254
- if (tmpLabelCell && tmpLabelCell.classList.contains('psrs-card-label')) { tmpLabelCell.remove(); }
255
- tmpValueCells[i].remove();
383
+ // Drop any field whose value rendered empty so a card never shows a "LABEL: (blank)" row the
384
+ // configured Fields are static, but which ones have data varies per record. (The rich body skips
385
+ // empties as it builds, so this only applies to the pre-compiled path.)
386
+ const tmpValueCells = tmpPopover.querySelectorAll('.psrs-card-fields .psrs-card-value');
387
+ for (let i = 0; i < tmpValueCells.length; i++)
388
+ {
389
+ if (String(tmpValueCells[i].textContent || '').trim() !== '') { continue; }
390
+ const tmpLabelCell = tmpValueCells[i].previousElementSibling;
391
+ if (tmpLabelCell && tmpLabelCell.classList.contains('psrs-card-label')) { tmpLabelCell.remove(); }
392
+ tmpValueCells[i].remove();
393
+ }
394
+ const tmpFieldsBox = tmpPopover.querySelector('.psrs-card-fields');
395
+ if (tmpFieldsBox && (tmpFieldsBox.children.length === 0)) { tmpFieldsBox.remove(); }
256
396
  }
257
- const tmpFieldsBox = tmpPopover.querySelector('.psrs-card-fields');
258
- if (tmpFieldsBox && (tmpFieldsBox.children.length === 0)) { tmpFieldsBox.remove(); }
259
397
  this.pict.CSSMap.injectCSS();
260
398
  this._openForKey = pKey;
399
+ this._lastShow = { Card: pCard, Record: pRecord, Anchor: pAnchorElement, Key: pKey, Options: tmpOptions }; // for the audit re-render
261
400
  this._position(tmpPopover, pAnchorElement);
262
401
  tmpPopover.classList.add('is-open');
263
402
  this._bindDismiss();
264
403
  }
265
404
 
405
+ // --- Rich card body (all columns + audit stripe) + the audit/copy actions ---
406
+
407
+ /** Build the dynamic card HTML: head + all meaningful columns + (when on) the audit stripe + actions. */
408
+ _buildRichCardHTML(pCard, pRecord, pOptions)
409
+ {
410
+ const tmpConfig = pCard.Config || {};
411
+ const tmpTitle = (tmpConfig.Title ? this._resolveText(tmpConfig.Title, pRecord) : '') || this._deriveTitle(pRecord, pCard);
412
+ let tmpHead = `<div class="psrs-card-head"><div><div class="psrs-card-title">${this._escapeHTML(tmpTitle)}</div>`;
413
+ const tmpSubtitle = tmpConfig.Subtitle ? this._resolveText(tmpConfig.Subtitle, pRecord) : '';
414
+ if (tmpSubtitle) { tmpHead += `<div class="psrs-card-subtitle">${this._escapeHTML(tmpSubtitle)}</div>`; }
415
+ tmpHead += '</div></div>';
416
+
417
+ // Skip the field the title already shows, so the body doesn't repeat it as a "Name: …" row.
418
+ let tmpSkipField = null;
419
+ if (tmpConfig.Title && (String(tmpConfig.Title).indexOf('{~') < 0)) { tmpSkipField = tmpConfig.Title; }
420
+ else if (!tmpConfig.Title && pRecord.Name) { tmpSkipField = 'Name'; }
421
+ let tmpBody = '';
422
+ const tmpBooleanFields = (this._schemaBooleans && this._schemaBooleans[pCard.Entity]) || {};
423
+ const tmpFields = this._richFields(pRecord, tmpSkipField, tmpBooleanFields);
424
+ if (tmpFields.length > 0)
425
+ {
426
+ tmpBody = '<div class="psrs-card-fields">';
427
+ tmpFields.forEach((pField) =>
428
+ {
429
+ const tmpValueCell = pField.IsBoolean ? this._boolHTML(pField.BoolOn) : this._escapeHTML(pField.Value);
430
+ tmpBody += `<span class="psrs-card-label">${this._escapeHTML(pField.Label)}</span><span class="psrs-card-value">${tmpValueCell}</span>`;
431
+ });
432
+ tmpBody += '</div>';
433
+ }
434
+
435
+ const tmpAudit = this.getAuditEnabled() ? this._auditStripeHTML(pCard, pRecord) : '';
436
+ const tmpActions = this._richActionsHTML(tmpConfig, pRecord, pOptions);
437
+ return `<div class="psrs-card">${tmpHead}${tmpBody}${tmpAudit}${tmpActions}</div>`;
438
+ }
439
+
440
+ /** Every real data column (skipping id/guid — surfaced via copy buttons — audit, nulls + objects). */
441
+ _richFields(pRecord, pSkipField, pBooleanFields)
442
+ {
443
+ const tmpBooleans = pBooleanFields || {};
444
+ const tmpFields = [];
445
+ Object.keys(pRecord || {}).forEach((pKey) =>
446
+ {
447
+ if (pKey === pSkipField) { return; }
448
+ if (/^(ID|GUID)/.test(pKey)) { return; }
449
+ if (/^(CreateDate|UpdateDate|CreatingIDUser|UpdatingIDUser|DeletingIDUser|DeleteDate|Deleted|CreatingUserName|UpdatingUserName)$/.test(pKey)) { return; }
450
+ const tmpValue = pRecord[pKey];
451
+ if ((tmpValue == null) || (tmpValue === '') || (typeof tmpValue === 'object')) { return; }
452
+ // Schema booleans render as a checkbox glyph, not their raw 0/1.
453
+ if (tmpBooleans[pKey])
454
+ {
455
+ tmpFields.push({ Label: this._humanizeLabel(pKey), IsBoolean: true, BoolOn: ((tmpValue === 1) || (tmpValue === '1') || (tmpValue === true) || (String(tmpValue).toLowerCase() === 'true')) });
456
+ return;
457
+ }
458
+ let tmpDisplay = String(tmpValue);
459
+ // Hide values that are only null/undefined tokens (e.g. a "null null" address from concatenated
460
+ // empty parts) — they shouldn't reach the user looking like broken software.
461
+ if (/^(null|undefined|nan)(\s+(null|undefined|nan))*$/i.test(tmpDisplay.trim())) { return; }
462
+ // Humanize ISO timestamps instead of dumping the raw `2026-06-21T05:47:01.000Z`.
463
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(tmpDisplay)) { tmpDisplay = this._fmtDate(tmpDisplay); }
464
+ tmpFields.push({ Label: this._humanizeLabel(pKey), Value: tmpDisplay });
465
+ });
466
+ return tmpFields;
467
+ }
468
+
469
+ /** A checkbox glyph for a schema-boolean value — filled when on, an empty box when off. */
470
+ _boolHTML(pOn)
471
+ {
472
+ const tmpCheck = (pOn && (typeof this.pict.icon === 'function')) ? this.pict.icon('Check') : '';
473
+ return `<span class="psrs-card-bool${pOn ? ' psrs-card-bool-on' : ''}">${pOn ? tmpCheck : ''}</span>`;
474
+ }
475
+
476
+ /** The thin audit stripe: Created/Updated date + user, plus ID/GUID copy buttons (value shown on hover). */
477
+ _auditStripeHTML(pCard, pRecord)
478
+ {
479
+ const tmpInfo = [];
480
+ if (pRecord.CreateDate) { tmpInfo.push(`<span>Created <b>${this._escapeHTML(this._fmtDate(pRecord.CreateDate))}</b>${pRecord.CreatingUserName ? ` by <b>${this._escapeHTML(pRecord.CreatingUserName)}</b>` : ''}</span>`); }
481
+ if (pRecord.UpdateDate) { tmpInfo.push(`<span>Updated <b>${this._escapeHTML(this._fmtDate(pRecord.UpdateDate))}</b>${pRecord.UpdatingUserName ? ` by <b>${this._escapeHTML(pRecord.UpdatingUserName)}</b>` : ''}</span>`); }
482
+ const tmpIcon = (typeof this.pict.icon === 'function') ? this.pict.icon('Copy') : '';
483
+ let tmpCopies = '';
484
+ const tmpID = pRecord[pCard.IDField];
485
+ const tmpGUID = pRecord[pCard.GUIDField];
486
+ if ((tmpID != null) && (tmpID !== '')) { tmpCopies += this._copyButtonHTML('ID', tmpID, tmpIcon); }
487
+ if (tmpGUID) { tmpCopies += this._copyButtonHTML('GUID', tmpGUID, tmpIcon); }
488
+ if ((tmpInfo.length < 1) && !tmpCopies) { return ''; }
489
+ return `<div class="psrs-card-audit"><div class="psrs-card-audit-info">${tmpInfo.join('<span class="psrs-card-audit-sep">·</span>')}</div>`
490
+ + `<div class="psrs-card-audit-copy">${tmpCopies}</div></div>`;
491
+ }
492
+
493
+ _copyButtonHTML(pLabel, pValue, pIcon)
494
+ {
495
+ const tmpValue = this._escapeAttribute(String(pValue));
496
+ return `<button type="button" class="psrs-card-copy" title="${tmpValue}" data-copy="${tmpValue}" onclick="event.stopPropagation();_Pict.providers.RecordSetCardManager.copyValue(this.getAttribute('data-copy'),this)">${pIcon}<span>${this._escapeHTML(pLabel)}</span></button>`;
497
+ }
498
+
499
+ /** The card's configured actions (View, …) + the global Audit toggle pushed to the right. */
500
+ _richActionsHTML(pConfig, pRecord, pOptions)
501
+ {
502
+ let tmpActions = '';
503
+ (Array.isArray(pConfig.Actions) ? pConfig.Actions : []).forEach((pAction) => { tmpActions += this._actionHTML(pAction, pRecord); });
504
+ const tmpAuditToggle = (pOptions && (pOptions.Audit === false)) ? ''
505
+ : `<label class="psrs-card-audit-toggle"><input type="checkbox" ${this.getAuditEnabled() ? 'checked ' : ''}onclick="event.stopPropagation();_Pict.providers.RecordSetCardManager.toggleAudit()" /><span>Audit</span></label>`;
506
+ if (!tmpActions && !tmpAuditToggle) { return ''; }
507
+ return `<div class="psrs-card-actions">${tmpActions}${tmpAuditToggle}</div>`;
508
+ }
509
+
510
+ /** Build one action anchor/button, resolving its Route/URL template + icon against the record. */
511
+ _actionHTML(pAction, pRecord)
512
+ {
513
+ const tmpIcon = (pAction.Icon && (typeof this.pict.icon === 'function')) ? this.pict.icon(pAction.Icon) : '';
514
+ const tmpLabel = `${tmpIcon}<span>${this._escapeHTML(pAction.Label || 'Open')}</span>`;
515
+ if (pAction.URL)
516
+ {
517
+ return `<a class="psrs-card-action" href="${this._escapeAttribute(this._resolveText(pAction.URL, pRecord))}" target="_blank" rel="noopener" onclick="_Pict.providers.RecordSetCardManager.closeCard()">${tmpLabel}</a>`;
518
+ }
519
+ if (pAction.Route)
520
+ {
521
+ return `<a class="psrs-card-action" href="${this._escapeAttribute(this._resolveText(pAction.Route, pRecord))}" onclick="_Pict.providers.RecordSetCardManager.closeCard()">${tmpLabel}</a>`;
522
+ }
523
+ if (pAction.Handler)
524
+ {
525
+ return `<button type="button" class="psrs-card-action" onclick="_Pict.providers.RecordSetCardManager.closeCard(); ${pAction.Handler}">${tmpLabel}</button>`;
526
+ }
527
+ return '';
528
+ }
529
+
530
+ /** Resolve a field-name-or-template config value against a record into a plain string. */
531
+ _resolveText(pValue, pRecord)
532
+ {
533
+ try { return this.pict.parseTemplate(this._valueTemplate(pValue), pRecord); }
534
+ catch (pError) { return ''; }
535
+ }
536
+
537
+ _deriveTitle(pRecord, pCard)
538
+ {
539
+ return pRecord.Name || pRecord.Title || pRecord.DisplayName
540
+ || `${pCard.Entity} #${(pRecord[pCard.IDField] != null) ? pRecord[pCard.IDField] : ''}`;
541
+ }
542
+
543
+ /** `MaterialCode` → `Material Code`, `mix_id` → `mix id`. */
544
+ _humanizeLabel(pKey)
545
+ {
546
+ return String(pKey).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
547
+ }
548
+
549
+ /** Human-readable date — `Jun 21, 2026 05:47` (or `Jun 21, 2026` with no time) from an ISO string. */
550
+ _fmtDate(pDate)
551
+ {
552
+ const tmpString = String((pDate == null) ? '' : pDate);
553
+ const tmpMatch = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(tmpString);
554
+ if (!tmpMatch) { return tmpString; }
555
+ const tmpMonths = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
556
+ const tmpMonth = tmpMonths[parseInt(tmpMatch[2], 10) - 1] || tmpMatch[2];
557
+ let tmpResult = `${tmpMonth} ${parseInt(tmpMatch[3], 10)}, ${tmpMatch[1]}`;
558
+ if (tmpMatch[4]) { tmpResult += ` ${tmpMatch[4]}:${tmpMatch[5]}`; }
559
+ return tmpResult;
560
+ }
561
+
562
+ _escapeHTML(pValue)
563
+ {
564
+ return String((pValue == null) ? '' : pValue).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
565
+ }
566
+
567
+ _escapeAttribute(pValue)
568
+ {
569
+ return String((pValue == null) ? '' : pValue).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
570
+ }
571
+
572
+ // --- Audit toggle (global, persisted) + clipboard copy ---
573
+
574
+ /** Whether the audit stripe is globally enabled (localStorage, with an in-memory fallback). */
575
+ getAuditEnabled()
576
+ {
577
+ try
578
+ {
579
+ if (typeof window !== 'undefined' && window.localStorage) { return window.localStorage.getItem('RecordSetCardManager.AuditStripe') === 'true'; }
580
+ }
581
+ catch (pError) { /* fall through to the in-memory copy */ }
582
+ return !!this._auditEnabled;
583
+ }
584
+
585
+ setAuditEnabled(pEnabled)
586
+ {
587
+ this._auditEnabled = !!pEnabled;
588
+ try
589
+ {
590
+ if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.setItem('RecordSetCardManager.AuditStripe', String(!!pEnabled)); }
591
+ }
592
+ catch (pError) { /* in-memory only */ }
593
+ }
594
+
595
+ /** Flip the global audit setting and repaint the open card so its stripe shows/hides immediately. */
596
+ toggleAudit()
597
+ {
598
+ this.setAuditEnabled(!this.getAuditEnabled());
599
+ if (!this._lastShow) { return; }
600
+ const tmpLast = this._lastShow;
601
+ if (this.getAuditEnabled())
602
+ {
603
+ return this._resolveAuditUsers(tmpLast.Record, () => this._renderAndShow(tmpLast.Card, tmpLast.Record, tmpLast.Anchor, tmpLast.Key, tmpLast.Options));
604
+ }
605
+ return this._renderAndShow(tmpLast.Card, tmpLast.Record, tmpLast.Anchor, tmpLast.Key, tmpLast.Options);
606
+ }
607
+
608
+ /** Copy a value (an ID / GUID) to the clipboard with a brief confirmation on the button. */
609
+ copyValue(pValue, pButton)
610
+ {
611
+ try
612
+ {
613
+ if ((typeof navigator !== 'undefined') && navigator.clipboard && (typeof navigator.clipboard.writeText === 'function'))
614
+ {
615
+ navigator.clipboard.writeText(String((pValue == null) ? '' : pValue));
616
+ }
617
+ }
618
+ catch (pError) { /* clipboard unavailable */ }
619
+ if (pButton && pButton.classList)
620
+ {
621
+ pButton.classList.add('psrs-card-copied');
622
+ setTimeout(() => { if (pButton.classList) { pButton.classList.remove('psrs-card-copied'); } }, 1100);
623
+ }
624
+ }
625
+
266
626
  _ensurePopover()
267
627
  {
268
628
  if (typeof document === 'undefined') { return null; }
@@ -301,6 +661,9 @@ class RecordSetCardManager extends libPictProvider
301
661
  {
302
662
  tmpTop = tmpRect.top + tmpScrollY - tmpHeight - tmpGap;
303
663
  }
664
+ // Clamp vertically so a tall (rich) card never runs off-screen — it scrolls internally past max-height.
665
+ const tmpBottomLimit = tmpScrollY + window.innerHeight - 8;
666
+ if (tmpTop + tmpHeight > tmpBottomLimit) { tmpTop = Math.max(tmpScrollY + 8, tmpBottomLimit - tmpHeight); }
304
667
  pPopover.style.left = `${Math.round(tmpLeft)}px`;
305
668
  pPopover.style.top = `${Math.round(tmpTop)}px`;
306
669
  }