@7365admin1/layer-common 3.2.2-staging.91 → 3.2.2-staging.92

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.
@@ -408,6 +408,139 @@ select.app-field__input {
408
408
  }
409
409
  }
410
410
 
411
+ /* ------------------------------------------------------------------ */
412
+ /* SegmentedControl - Today | Week | Month. */
413
+ /* ------------------------------------------------------------------ */
414
+
415
+ /**
416
+ * The handoff draws this as a 3px-padded bordered tray holding 8px-radius
417
+ * segments; the SELECTED one is an ACCENT FILL, not a grey one. Vuetify's
418
+ * `v-btn-toggle` cannot be talked into that shape (its own height scale, its
419
+ * dividers, its overlay layer), so the tray is written as markup.
420
+ *
421
+ * The fill is `--accent-strong`, not `--accent`: white on `--accent` measures
422
+ * 3.23:1, the same Phase-1 rule every other filled label follows.
423
+ */
424
+ .app-seg {
425
+ display: flex;
426
+ gap: 2px;
427
+ padding: 3px;
428
+ border: 1px solid var(--border);
429
+ border-radius: var(--r-inner);
430
+ background: var(--card);
431
+ }
432
+
433
+ .app-seg__item {
434
+ padding: 6px 12px;
435
+ border: none;
436
+ border-radius: 8px;
437
+ background: transparent;
438
+ color: var(--muted);
439
+ font-family: inherit;
440
+ font-size: 12.5px;
441
+ font-weight: 700;
442
+ line-height: 1.2;
443
+ white-space: nowrap;
444
+ cursor: pointer;
445
+ }
446
+
447
+ .app-seg__item:hover {
448
+ background: var(--hover);
449
+ }
450
+
451
+ .app-seg__item--on,
452
+ .app-seg__item--on:hover {
453
+ background: var(--accent-strong);
454
+ color: var(--on-accent-strong);
455
+ }
456
+
457
+ /* ------------------------------------------------------------------ */
458
+ /* KpiTile - the dashboard's headline figure. */
459
+ /* ------------------------------------------------------------------ */
460
+
461
+ /**
462
+ * Three rows, in this order, straight from the handoff:
463
+ * 1. a 34px soft icon square and the uppercase label, side by side
464
+ * 2. the 30px tabular number with its percentage pill BESIDE it
465
+ * 3. the sub-line
466
+ * The product had the icon alone on row 1 and the pill stranded at the
467
+ * bottom, which is why the tile read as a different component.
468
+ */
469
+ .app-kpi {
470
+ padding: 18px;
471
+ }
472
+
473
+ .app-kpi__head {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 10px;
477
+ margin-bottom: 14px;
478
+ }
479
+
480
+ .app-kpi__icon {
481
+ display: flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ width: 34px;
485
+ height: 34px;
486
+ flex: 0 0 34px;
487
+ border-radius: var(--r-inner);
488
+ }
489
+
490
+ .app-kpi__label {
491
+ font-size: var(--fs-table-head);
492
+ font-weight: var(--fw-table-head);
493
+ letter-spacing: var(--ls-table-head);
494
+ text-transform: uppercase;
495
+ color: var(--muted);
496
+ min-width: 0;
497
+ }
498
+
499
+ .app-kpi__action {
500
+ margin-left: auto;
501
+ flex: 0 0 auto;
502
+ }
503
+
504
+ .app-kpi__figure {
505
+ display: flex;
506
+ align-items: center;
507
+ gap: 10px;
508
+ flex-wrap: wrap;
509
+ }
510
+
511
+ .app-kpi__value {
512
+ font-size: var(--fs-kpi);
513
+ font-weight: var(--fw-kpi);
514
+ letter-spacing: var(--ls-kpi);
515
+ line-height: 1.1;
516
+ color: var(--text);
517
+ font-variant-numeric: tabular-nums;
518
+ }
519
+
520
+ .app-kpi__chip {
521
+ display: inline-flex;
522
+ align-items: center;
523
+ padding: 3px 8px;
524
+ border-radius: 999px;
525
+ font-size: 11.5px;
526
+ font-weight: 800;
527
+ line-height: 1.2;
528
+ background: var(--ok-bg);
529
+ color: var(--ok);
530
+ }
531
+
532
+ .app-kpi__chip--down {
533
+ background: var(--err-bg);
534
+ color: var(--err);
535
+ }
536
+
537
+ .app-kpi__sub {
538
+ margin-top: 6px;
539
+ font-size: 12.5px;
540
+ font-weight: 600;
541
+ color: var(--muted);
542
+ }
543
+
411
544
  /* ------------------------------------------------------------------ */
412
545
  /* Responsive - the prototype has no reference, this follows Phase 1. */
413
546
  /* ------------------------------------------------------------------ */
@@ -0,0 +1,65 @@
1
+ <!--
2
+ THE KPI TILE.
3
+
4
+ The handoff's tile is three rows: the soft icon square AND the uppercase
5
+ label together on the first, the big tabular number with its percentage pill
6
+ BESIDE it on the second, the sub-line on the third. The product had the icon
7
+ alone on a row of its own, the label under it, and the pill stranded at the
8
+ bottom next to the sub-line - the same figures in a different shape.
9
+
10
+ It draws what it is given and nothing else: no fetch, no formatting, no
11
+ threshold logic. `#action` is the far end of the first row (a filter menu, a
12
+ "View All"); `#default`, when given, replaces the figure rows entirely for
13
+ the tiles that list items instead of counting them.
14
+ -->
15
+ <template>
16
+ <AppCard class="app-kpi d-flex flex-column flex-grow-1">
17
+ <div class="app-kpi__head">
18
+ <span
19
+ v-if="icon"
20
+ class="app-kpi__icon"
21
+ :style="tint ? { backgroundColor: tint } : undefined"
22
+ >
23
+ <v-icon :icon="icon" :color="color || undefined" size="19" />
24
+ </span>
25
+
26
+ <span class="app-kpi__label">{{ label }}</span>
27
+
28
+ <span v-if="$slots.action" class="app-kpi__action">
29
+ <slot name="action" />
30
+ </span>
31
+ </div>
32
+
33
+ <slot>
34
+ <div class="app-kpi__figure">
35
+ <span class="app-kpi__value">{{ value }}</span>
36
+
37
+ <span
38
+ v-if="chip"
39
+ class="app-kpi__chip"
40
+ :class="{ 'app-kpi__chip--down': down }"
41
+ >
42
+ {{ chip }}
43
+ </span>
44
+ </div>
45
+
46
+ <div v-if="sub" class="app-kpi__sub">{{ sub }}</div>
47
+ </slot>
48
+ </AppCard>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ defineProps({
53
+ icon: { type: String, default: "" },
54
+ /** The icon square's soft fill - a token-derived colour from the caller. */
55
+ tint: { type: String, default: "" },
56
+ color: { type: String, default: "" },
57
+ label: { type: String, default: "" },
58
+ value: { type: [String, Number], default: "" },
59
+ /** The percentage pill's text, already formatted by the caller. */
60
+ chip: { type: String, default: "" },
61
+ /** Draws the pill in the error tone rather than the success one. */
62
+ down: { type: Boolean, default: false },
63
+ sub: { type: String, default: "" },
64
+ });
65
+ </script>
@@ -0,0 +1,38 @@
1
+ <!--
2
+ THE SEGMENTED CONTROL - `Today | Week | Month`.
3
+
4
+ A tray of plain `<button>`s, not a `v-btn-toggle`. The toggle brings its own
5
+ height scale, dividers, ripple and overlay layer, and its selected state is
6
+ drawn from `on-surface` - which is why the product's selected segment reads
7
+ grey where the design fills it with the accent.
8
+
9
+ Selection only: it writes the same `v-model` the toggle wrote, and the value
10
+ set is whatever the caller passes. No behaviour is added here.
11
+ -->
12
+ <template>
13
+ <div class="app-seg" role="group">
14
+ <button
15
+ v-for="option in options"
16
+ :key="String(option.value)"
17
+ type="button"
18
+ class="app-seg__item"
19
+ :class="{ 'app-seg__item--on': model === option.value }"
20
+ :aria-pressed="model === option.value"
21
+ @click="model = option.value"
22
+ >
23
+ {{ option.label }}
24
+ </button>
25
+ </div>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ defineProps({
30
+ /** `{ label, value }` - the same option list the toggle was given. */
31
+ options: {
32
+ type: Array as PropType<Array<{ label: string; value: any }>>,
33
+ default: () => [],
34
+ },
35
+ });
36
+
37
+ const model = defineModel<any>({ default: "" });
38
+ </script>
@@ -1,6 +1,18 @@
1
1
  <template>
2
2
  <v-avatar :color="getColor(name || id)" :size="size">
3
- <v-img v-if="imageSrc" alt="John" :src="imageUrl" />
3
+ <!--
4
+ A stored profile photo that 404s left the circle EMPTY - `v-img`
5
+ draws nothing when the source fails, and there was no fallback under
6
+ it. The initials the avatar already computes stand in, so the circle
7
+ is never blank.
8
+ -->
9
+ <v-img v-if="imageSrc" :alt="name" :src="imageUrl">
10
+ <template #error>
11
+ <div class="d-flex align-center justify-center fill-height">
12
+ <span :style="{ fontSize: `${fontSize}px` }">{{ initials }}</span>
13
+ </div>
14
+ </template>
15
+ </v-img>
4
16
  <span v-else :style="{
5
17
  fontSize: `${fontSize}px`,
6
18
  lineHeight: `${size}px`
@@ -203,44 +203,36 @@
203
203
 
204
204
  <section v-else class="unified-dashboard pb-8">
205
205
  <!-- ── Header ──────────────────────────────────────────────────────────── -->
206
- <div
207
- class="d-flex flex-column flex-md-row align-md-start justify-space-between ga-5 mb-6"
206
+ <PageHeader
207
+ :title="dashboardTitle"
208
+ :subtitle="currentSiteName || 'Selected site'"
209
+ subtitle-icon="mdi-map-marker"
208
210
  >
209
- <div>
210
- <h1 class="dashboard-title mb-0">
211
- {{ dashboardTitle }}
212
- </h1>
213
- <div class="d-flex align-center text-medium-emphasis ga-1 mt-2 text-caption">
214
- <v-icon icon="mdi-map-marker" size="16" />
215
- <span>{{ currentSiteName || "Selected site" }}</span>
216
- </div>
217
- </div>
218
-
219
- <div
220
- v-if="showHeaderActions"
221
- class="d-flex flex-wrap align-center justify-md-end ga-3"
222
- >
223
- <v-btn
224
- variant="outlined"
225
- class="text-none font-weight-bold"
226
- prepend-icon="mdi-tune-variant"
211
+ <!--
212
+ The design's three actions: two 38px ghost buttons and the accent
213
+ segmented control. Same dialog, same export menu, same range model -
214
+ `Customize Dashboard` and `Export` shorten to the labels the prototype
215
+ prints, and the export menu keeps both of its formats.
216
+ -->
217
+ <template v-if="showHeaderActions" #actions>
218
+ <AppButton
219
+ variant="ghost"
220
+ icon="mdi-tune-variant"
227
221
  @click="openCustomizeDialog"
228
222
  >
229
- Customize Dashboard
230
- </v-btn>
223
+ Customize
224
+ </AppButton>
231
225
 
232
226
  <v-menu>
233
227
  <template #activator="{ props: menuProps }">
234
- <v-btn
228
+ <AppButton
235
229
  v-bind="menuProps"
236
- variant="outlined"
237
- class="text-none font-weight-bold"
238
- append-icon="mdi-chevron-down"
239
- :loading="isExporting"
230
+ variant="ghost"
231
+ icon="mdi-download-outline"
240
232
  :disabled="isExporting"
241
233
  >
242
234
  Export
243
- </v-btn>
235
+ </AppButton>
244
236
  </template>
245
237
 
246
238
  <v-list density="compact" border rounded="lg" elevation="3">
@@ -249,36 +241,9 @@
249
241
  </v-list>
250
242
  </v-menu>
251
243
 
252
- <v-btn-toggle
253
- v-model="selectedRange"
254
- mandatory
255
- density="comfortable"
256
- divided
257
- variant="outlined"
258
- class="bg-surface"
259
- style="border-radius: 8px"
260
- >
261
- <v-btn
262
- v-for="range in rangeOptions"
263
- :key="range.value"
264
- :value="range.value"
265
- class="text-none"
266
- size="small"
267
- :style="
268
- selectedRange === range.value
269
- ? {
270
- backgroundColor: 'rgba(var(--v-theme-on-surface), 0.12)',
271
- color: 'rgb(var(--v-theme-on-surface))',
272
- fontWeight: '600',
273
- }
274
- : { backgroundColor: 'transparent', color: 'rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))' }
275
- "
276
- >
277
- {{ range.label }}
278
- </v-btn>
279
- </v-btn-toggle>
280
- </div>
281
- </div>
244
+ <AppSegmented v-model="selectedRange" :options="rangeOptions" />
245
+ </template>
246
+ </PageHeader>
282
247
 
283
248
  <v-progress-linear
284
249
  v-if="finalLoading"
@@ -298,20 +263,24 @@
298
263
  lg="3"
299
264
  class="d-flex"
300
265
  >
301
- <v-card
302
- flat
303
- class="dashboard-card dashboard-kpi-card pa-4 flex-grow-1"
304
- style="min-height: 158px"
266
+ <!--
267
+ The same figures the tile has always shown, in the arrangement the
268
+ handoff draws them: icon and label on one row, the number with its
269
+ percentage pill beside it, the sub-line under both. The filter menu
270
+ and the facility "View All" keep their place at the end of the first
271
+ row and do exactly what they did.
272
+ -->
273
+ <AppKpiTile
274
+ :icon="card.icon"
275
+ :tint="card.tint"
276
+ :color="card.color"
277
+ :label="card.title"
278
+ :value="card.value"
279
+ :chip="kpiChipText(card)"
280
+ :down="Number(card.percentage) < 0"
281
+ :sub="card.meta"
305
282
  >
306
- <div class="d-flex align-start justify-space-between">
307
- <div
308
- class="d-inline-flex align-center justify-center rounded-lg"
309
- style="height: 28px; width: 28px; flex-shrink: 0"
310
- :style="{ backgroundColor: card.tint }"
311
- >
312
- <v-icon :icon="card.icon" :color="card.color" size="15" />
313
- </div>
314
-
283
+ <template v-if="card.filter || card.key === 'facilityBookings'" #action>
315
284
  <v-menu v-if="card.filter" v-model="filterMenus[card.key]">
316
285
  <template #activator="{ props: menuProps }">
317
286
  <v-btn
@@ -355,59 +324,24 @@
355
324
  >
356
325
  View All
357
326
  </button>
358
- </div>
327
+ </template>
359
328
 
360
- <div class="mt-6 w-100">
361
- <p class="dashboard-kpi-label mb-3">
362
- {{ card.title }}
363
- </p>
364
- <template
365
- v-if="
366
- card.key === 'supplyAlert' && Array.isArray(card.raw?.items)
367
- "
368
- >
369
- <div class="d-flex flex-column ga-2 mt-2">
370
- <div
371
- v-for="item in card.raw.items"
372
- :key="item._id || item.name"
373
- class="d-flex justify-space-between text-body-2 text-high-emphasis font-weight-medium"
374
- >
375
- <span>{{ item.name }}</span>
376
- <span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
377
- </div>
378
- </div>
379
- </template>
380
- <template v-else>
381
- <div class="d-flex align-center mb-3" style="min-height: 31px">
382
- <span class="dashboard-kpi-value">
383
- {{ card.value }}
384
- </span>
385
- </div>
386
- <div class="d-flex align-center flex-wrap ga-2">
387
- <span v-if="card.meta" class="text-caption text-medium-emphasis">{{
388
- card.meta
389
- }}</span>
390
- <v-chip
391
- :color="card.percentage >= 0 ? 'success' : 'error'"
392
- size="x-small"
393
- variant="flat"
394
- class="font-weight-bold"
395
- >
396
- <v-icon
397
- :icon="
398
- card.percentage >= 0
399
- ? 'mdi-arrow-up-right'
400
- : 'mdi-arrow-down-right'
401
- "
402
- size="13"
403
- start
404
- />
405
- {{ Math.abs(card.percentage) }}%
406
- </v-chip>
329
+ <!-- The supply tile lists items instead of counting one figure. -->
330
+ <template
331
+ v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
332
+ >
333
+ <div class="d-flex flex-column ga-2">
334
+ <div
335
+ v-for="item in card.raw.items"
336
+ :key="item._id || item.name"
337
+ class="d-flex justify-space-between text-body-2 text-high-emphasis font-weight-medium"
338
+ >
339
+ <span>{{ item.name }}</span>
340
+ <span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
407
341
  </div>
408
- </template>
409
- </div>
410
- </v-card>
342
+ </div>
343
+ </template>
344
+ </AppKpiTile>
411
345
  </v-col>
412
346
  </v-row>
413
347
 
@@ -2491,6 +2425,17 @@ const visibleKpiCards = computed(() =>
2491
2425
  kpiCards.value.filter((card) => isWidgetVisible(card.key))
2492
2426
  );
2493
2427
 
2428
+ /**
2429
+ * The percentage pill's label. Same number the chip has always shown, written
2430
+ * the way the handoff prints it - `+0%` / `-6.4%`, the sign carrying what the
2431
+ * arrow icon used to. A card without a percentage gets no pill.
2432
+ */
2433
+ function kpiChipText(card: Record<string, any>): string {
2434
+ const value = Number(card?.percentage);
2435
+ if (!Number.isFinite(value)) return "";
2436
+ return `${value < 0 ? "-" : "+"}${Math.abs(value)}%`;
2437
+ }
2438
+
2494
2439
  // ─── Dashboard Title ──────────────────────────────────────────────────────────
2495
2440
 
2496
2441
  const dashboardTitle = computed(() => {
@@ -126,7 +126,18 @@ const { currentUser } = useLocalAuth();
126
126
  */
127
127
  const { site } = useSite();
128
128
 
129
- const siteName = computed(() => String(site.value?.name ?? "").trim());
129
+ /**
130
+ * The shared `site` state is only populated by the screens that load a site
131
+ * document, so on most screens - the dashboard included - the bar had no name
132
+ * to print. `SwitchContext` mirrors the label it is ALREADY showing in the
133
+ * rail into this state, so the bar falls back to the very name the user can
134
+ * see two inches to the left. Still no request from this component.
135
+ */
136
+ const shellSiteName = useState<string>("shell-context-site-name", () => "");
137
+
138
+ const siteName = computed(() =>
139
+ String(site.value?.name || shellSiteName.value || "").trim()
140
+ );
130
141
 
131
142
  const route = useRoute();
132
143
 
@@ -188,8 +199,11 @@ const name = computed(() => {
188
199
 
189
200
  if(full) return full;
190
201
 
202
+ /* A member invited by email has no name stored yet, which left the avatar's
203
+ initials empty and the circle blank. The address is what the product shows
204
+ that member everywhere else, so it stands in here too. */
191
205
  const alternative = user?.name?.trim();
192
- return alternative || ""
206
+ return alternative || user?.email?.trim() || ""
193
207
 
194
208
  })
195
209
 
@@ -100,6 +100,24 @@ const name = computed(() => {
100
100
  return prop.items.find((item) => item.value === selected.value)?.text ?? "";
101
101
  });
102
102
 
103
+ /**
104
+ * THE BREADCRUMB'S SITE HALF.
105
+ *
106
+ * The top bar draws `Site name / Page`, but the site's NAME is only ever
107
+ * resolved here - each application loads its own site list and hands it to
108
+ * this selector, so the bar had nothing to print and showed the page alone.
109
+ * The label already on screen is mirrored into shared state for the bar to
110
+ * read. It is a label: no request is made, and what the selector selects is
111
+ * untouched.
112
+ */
113
+ const shellSiteName = useState<string>("shell-context-site-name", () => "");
114
+
115
+ watchEffect(() => {
116
+ if (prop.title?.toLowerCase() === "site" && name.value) {
117
+ shellSiteName.value = name.value;
118
+ }
119
+ });
120
+
103
121
  const selectItem = (value: string) => {
104
122
  selected.value = value;
105
123
  menu.value = false;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.91",
5
+ "version": "3.2.2-staging.92",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {