@7365admin1/layer-common 3.2.1-staging.75 → 3.2.2-staging.100

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.
Files changed (87) hide show
  1. package/.changeset/notification-preferences.md +17 -0
  2. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  3. package/CHANGELOG.md +6 -0
  4. package/assets/css/primitives.css +680 -0
  5. package/assets/css/screens.css +541 -0
  6. package/assets/css/shell.css +258 -0
  7. package/assets/css/tokens.css +220 -0
  8. package/components/AccessCardQrTagging.vue +1 -1
  9. package/components/AccessManagement.vue +53 -72
  10. package/components/AppButton.vue +52 -0
  11. package/components/AppCard.vue +20 -0
  12. package/components/AppDateField.vue +35 -0
  13. package/components/AppField.vue +46 -0
  14. package/components/AppKpiTile.vue +65 -0
  15. package/components/AppSegmented.vue +38 -0
  16. package/components/AppSelect.vue +203 -0
  17. package/components/AreaChecklistHistoryMain.vue +6 -2
  18. package/components/AreaMain.vue +9 -17
  19. package/components/AttendanceMain.vue +4 -3
  20. package/components/Avatar/Main.vue +54 -2
  21. package/components/BillingMain.vue +26 -30
  22. package/components/BuildingManagement/buildings.vue +27 -38
  23. package/components/BuildingManagement/units.vue +26 -42
  24. package/components/BulletinBoardManagement.vue +23 -10
  25. package/components/CardToolbar.vue +37 -0
  26. package/components/CleaningScheduleMain.vue +29 -46
  27. package/components/ClientMain.vue +90 -78
  28. package/components/DashboardEmptyState.vue +11 -1
  29. package/components/DashboardMain.vue +285 -207
  30. package/components/DocumentManagement.vue +129 -93
  31. package/components/EntryPassMain.vue +49 -27
  32. package/components/EquipmentItemMain.vue +4 -6
  33. package/components/EquipmentManagementMain.vue +9 -7
  34. package/components/FeedbackMain.vue +35 -49
  35. package/components/FormDialog.vue +60 -21
  36. package/components/HidIntercomManagement.vue +40 -24
  37. package/components/HidQrCodeConfiguration.vue +24 -2
  38. package/components/HidUserEnrollment.vue +68 -31
  39. package/components/InvitationMain.vue +46 -61
  40. package/components/Layout/Header.vue +289 -37
  41. package/components/Layout/NavigationDrawer.vue +299 -30
  42. package/components/ManageChecklistMain.vue +17 -17
  43. package/components/MemberMain.vue +79 -60
  44. package/components/MyAttendanceMain.vue +3 -5
  45. package/components/Nfc/NFCPatrolRouteMain.vue +64 -96
  46. package/components/NotificationSettings.vue +361 -0
  47. package/components/OnlineFormsConfiguration.vue +1 -1
  48. package/components/PageHeader.vue +43 -0
  49. package/components/PassInformation.vue +104 -45
  50. package/components/RolePermissionMain.vue +184 -91
  51. package/components/ScanVisitorQRCode.vue +35 -7
  52. package/components/ScheduleAreaMain.vue +15 -49
  53. package/components/ScheduleTaskMain.vue +23 -26
  54. package/components/ScheduleTastTicketMain.vue +18 -43
  55. package/components/ServiceProviderMain.vue +113 -168
  56. package/components/SiteSettings.vue +21 -8
  57. package/components/SpecificAttr.vue +10 -2
  58. package/components/StatusChip.vue +93 -0
  59. package/components/SwitchContext.vue +33 -1
  60. package/components/TableHygiene.vue +29 -46
  61. package/components/TableList.vue +1 -1
  62. package/components/TableListSecondary.vue +1 -1
  63. package/components/TableMain.vue +161 -77
  64. package/components/TableV3.vue +61 -66
  65. package/components/UnitMain.vue +9 -17
  66. package/components/VehicleManagement.vue +20 -9
  67. package/components/VisitorForm.vue +100 -7
  68. package/components/VisitorManagement.vue +219 -68
  69. package/components/WorkOrder/Main.vue +42 -51
  70. package/composables/useHidAmico.ts +26 -2
  71. package/composables/useNotificationPreference.ts +87 -0
  72. package/composables/useOrg.ts +1 -1
  73. package/composables/useRole.ts +22 -0
  74. package/composables/useSipWebPhone.ts +28 -3
  75. package/composables/useThemePreference.ts +63 -0
  76. package/composables/useVisitor.ts +6 -1
  77. package/nuxt.config.ts +31 -0
  78. package/package.json +3 -2
  79. package/pages/notification-settings.vue +19 -0
  80. package/plugins/vuetify.ts +32 -1
  81. package/types/org.d.ts +2 -0
  82. package/utils/nav-sections.test.ts +190 -0
  83. package/utils/nav-sections.ts +151 -0
  84. package/utils/status.test.ts +77 -0
  85. package/utils/status.ts +90 -0
  86. package/utils/theme.test.ts +325 -7
  87. package/utils/theme.ts +268 -137
@@ -1,85 +1,71 @@
1
1
  <template>
2
2
  <v-row no-gutters>
3
3
  <v-col cols="12">
4
- <v-row no-gutters class="mb-4" align="center">
5
- <v-col cols="12" md="6">
6
- <v-btn
4
+ <!-- Title row; nav calls this screen "Routes". The Add pill moves onto it
5
+ and the search onto the card's toolbar, where the design draws them. -->
6
+ <PageHeader title="Routes">
7
+ <template #actions>
8
+ <AppButton
7
9
  v-if="props.canCreateNfcPatrol"
8
- rounded="pill"
9
- variant="outlined"
10
-
11
- size="large"
12
- class="text-none px-8"
10
+ icon="mdi-plus"
13
11
  @click="dialogAdd = true"
14
12
  >
15
13
  Add Route
16
- </v-btn>
17
- </v-col>
18
- <v-col cols="12" md="6" class="d-flex justify-end">
19
- <v-text-field
14
+ </AppButton>
15
+ </template>
16
+ </PageHeader>
17
+
18
+ <!--
19
+ Was `v-card` + `v-data-table` + a `grey-lighten-4` `v-toolbar` BELOW the
20
+ table holding the page range and pager (a white slab that stayed white in
21
+ dark mode). `TableMain` draws the same card with that row on top, which
22
+ is where the design puts it. Same headers, items, page size, row click
23
+ and item slots; the refresh button is wired to the refresh this screen
24
+ already calls after add/edit.
25
+ -->
26
+ <TableMain
27
+ :headers="headers"
28
+ :items="items"
29
+ :page="page"
30
+ :pages="pages"
31
+ :pageRange="pageRange"
32
+ show-header
33
+ @refresh="getRoutesRefresh()"
34
+ @update:page="page = $event"
35
+ @row-click="(data: any) => handleRowClick(null, data)"
36
+ >
37
+ <template #prepend-additional>
38
+ <AppField
20
39
  v-model="searchInput"
21
- density="comfortable"
40
+ search
41
+ compact
22
42
  placeholder="Search"
23
- clearable
24
- append-inner-icon="mdi-magnify"
25
- hide-details
26
- variant="outlined"
27
- rounded="lg"
28
- style="max-width: 400px"
29
- bg-color="white"
43
+ klass="app-toolbar__search"
30
44
  />
31
- </v-col>
32
- </v-row>
33
-
34
- <v-card width="100%" variant="outlined" border="thin" rounded="lg">
35
-
36
- <v-card-text class="pa-0">
37
- <v-data-table
38
- :headers="headers"
39
- :items="items"
40
- :items-per-page="20"
41
- fixed-header
42
- hide-default-footer
43
- @click:row="handleRowClick"
44
- style="max-height: calc(100vh - 300px)"
45
- class="nfc-route-table"
46
- hover
47
- >
48
- <template #item.numberOfCheckpoints="{ value }">
49
- <div class="text-center">{{ value || "--" }}</div>
50
- </template>
51
-
52
- <template #item.tolerance="{ item }">
53
- <div class="text-center">
54
- <div>{{ item.tolerance ? `${item.tolerance} mins` : "--" }}</div>
55
- <div class="text-grey">{{ item.totalTravelTime ? `${item.totalTravelTime} mins` : "--" }}</div>
56
- </div>
57
- </template>
58
-
59
- <template #item.schedules="{ value }">
60
- <span v-if="Array.isArray(value) && value.length > 0">
61
- {{ value.map(s => `${s.start}-${s.end}`).join(", ") }}
62
- </span>
63
- <span v-else>--</span>
64
- </template>
65
-
66
- <template #item.repeatDays="{ value }">
67
- <span>{{ value || "--" }} </span>
68
- </template>
69
- </v-data-table>
70
- </v-card-text>
71
-
72
- <v-toolbar density="compact" color="grey-lighten-4">
73
- <template #append>
74
- <v-row no-gutters justify="end" align="center">
75
- <span class="mr-2 text-caption text-fontgray">
76
- {{ pageRange }}
77
- </span>
78
- <local-pagination v-model="page" :length="pages" />
79
- </v-row>
80
- </template>
81
- </v-toolbar>
82
- </v-card>
45
+ </template>
46
+
47
+ <template #item.numberOfCheckpoints="{ value }">
48
+ <div class="text-center">{{ value || "--" }}</div>
49
+ </template>
50
+
51
+ <template #item.tolerance="{ item }">
52
+ <div class="text-center">
53
+ <div>{{ item.tolerance ? `${item.tolerance} mins` : "--" }}</div>
54
+ <div class="text-grey">{{ item.totalTravelTime ? `${item.totalTravelTime} mins` : "--" }}</div>
55
+ </div>
56
+ </template>
57
+
58
+ <template #item.schedules="{ value }">
59
+ <span v-if="Array.isArray(value) && value.length > 0">
60
+ {{ value.map((s: any) => `${s.start}-${s.end}`).join(", ") }}
61
+ </span>
62
+ <span v-else>--</span>
63
+ </template>
64
+
65
+ <template #item.repeatDays="{ value }">
66
+ <span>{{ value || "--" }} </span>
67
+ </template>
68
+ </TableMain>
83
69
  </v-col>
84
70
 
85
71
  <!-- Add Dialog -->
@@ -558,27 +544,9 @@ async function submitDelete() {
558
544
  }
559
545
  }
560
546
  </script>
561
-
562
- <style scoped>
563
- .nfc-route-table :deep(.v-data-table__th) {
564
- background-color: rgb(250, 250, 250);
565
- font-weight: 500;
566
- white-space: pre-line;
567
- padding: 16px 12px !important;
568
- border-bottom: 1px solid rgba(0, 0, 0, 0.12);
569
- }
570
-
571
- .nfc-route-table :deep(.v-data-table__td) {
572
- padding: 16px 12px !important;
573
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
574
- }
575
-
576
- .nfc-route-table :deep(tbody tr:hover) {
577
- background-color: rgba(0, 0, 0, 0.02) !important;
578
- cursor: pointer;
579
- }
580
-
581
- .nfc-route-table :deep(.v-data-table__tr) {
582
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
583
- }
584
- </style>
547
+ <!--
548
+ The screen's own table CSS is gone with the `v-data-table` it styled: it
549
+ hardcoded an `rgb(250,250,250)` header fill and black-alpha borders, which
550
+ stay light in dark mode. `TableMain` draws the design's header row and
551
+ borders from the theme tokens instead.
552
+ -->
@@ -0,0 +1,361 @@
1
+ <template>
2
+ <v-row no-gutters class="pa-8" justify="center">
3
+ <v-col cols="12" md="10">
4
+ <v-row no-gutters>
5
+ <v-col cols="12" class="text-h5">Notifications</v-col>
6
+
7
+ <v-col cols="12" md="8" class="mt-4 font-weight-light">
8
+ Choose what you want to hear about, and how. These settings are yours
9
+ alone and apply everywhere you use iService — on this site and on your
10
+ phone.
11
+ </v-col>
12
+
13
+ <!-- loading -->
14
+ <v-col v-if="loading" cols="12" class="mt-8">
15
+ <v-skeleton-loader type="list-item-two-line@4" />
16
+ </v-col>
17
+
18
+ <!-- could not load -->
19
+ <v-col v-else-if="loadError" cols="12" class="mt-8">
20
+ <v-alert type="error" variant="tonal" rounded="lg">
21
+ {{ loadError }}
22
+ </v-alert>
23
+ </v-col>
24
+
25
+ <!-- nothing to offer -->
26
+ <v-col v-else-if="!categories.length" cols="12" class="mt-8">
27
+ <v-alert type="info" variant="tonal" rounded="lg">
28
+ You are not set up to receive any notifications yet. Once you are
29
+ given access to a part of the system, its settings will appear here.
30
+ </v-alert>
31
+ </v-col>
32
+
33
+ <template v-else>
34
+ <!-- everything, at once -->
35
+ <v-col cols="12" class="mt-8">
36
+ <v-card
37
+ width="100%"
38
+ border="grey-darken-3 sm"
39
+ variant="outlined"
40
+ rounded="lg"
41
+ >
42
+ <v-row no-gutters align="center" class="pa-6">
43
+ <v-col cols="12" sm="8">
44
+ <div class="text-h6">Everything</div>
45
+ <div class="text-body-2 text-medium-emphasis mt-1">
46
+ {{ everythingSummary }}
47
+ </div>
48
+ </v-col>
49
+ <v-col cols="12" sm="4" class="d-flex justify-sm-end mt-3 mt-sm-0">
50
+ <v-btn
51
+ variant="tonal"
52
+ rounded="xl"
53
+ class="text-none"
54
+ :disabled="saving"
55
+ @click="setAll(!allOn)"
56
+ >
57
+ {{ allOn ? "Turn everything off" : "Turn everything on" }}
58
+ </v-btn>
59
+ </v-col>
60
+ </v-row>
61
+ </v-card>
62
+ </v-col>
63
+
64
+ <!-- one panel per module, collapsed by default so a person with many
65
+ modules gets a short list rather than a wall of switches -->
66
+ <v-col cols="12" class="mt-4">
67
+ <v-expansion-panels variant="accordion" multiple>
68
+ <v-expansion-panel
69
+ v-for="category in categories"
70
+ :key="category.key"
71
+ :value="category.key"
72
+ elevation="0"
73
+ class="border-sm rounded-lg mb-2"
74
+ >
75
+ <v-expansion-panel-title>
76
+ <v-row no-gutters align="center">
77
+ <v-col cols="12" sm="6">
78
+ <span class="text-subtitle-1 font-weight-medium">
79
+ {{ category.label }}
80
+ </span>
81
+ <v-chip
82
+ v-if="category.safety"
83
+ size="x-small"
84
+ class="ml-2"
85
+ variant="tonal"
86
+ color="info"
87
+ >
88
+ Safety
89
+ </v-chip>
90
+ </v-col>
91
+ <v-col
92
+ cols="12"
93
+ sm="6"
94
+ class="text-body-2 text-medium-emphasis"
95
+ >
96
+ {{ summaryFor(category) }}
97
+ </v-col>
98
+ </v-row>
99
+ </v-expansion-panel-title>
100
+
101
+ <v-expansion-panel-text>
102
+ <div class="text-body-2 text-medium-emphasis mb-4">
103
+ {{ category.description }}
104
+ </div>
105
+
106
+ <v-btn
107
+ size="small"
108
+ variant="outlined"
109
+ rounded="xl"
110
+ class="text-none mb-4"
111
+ :disabled="saving || !switchableIn(category).length"
112
+ @click="setCategory(category, !categoryAllOn(category))"
113
+ >
114
+ {{
115
+ categoryAllOn(category)
116
+ ? "Turn all off for " + category.label
117
+ : "Turn all on for " + category.label
118
+ }}
119
+ </v-btn>
120
+
121
+ <!-- one channel per row: a switch must never sit beside the
122
+ NEXT channel's label, which is what a three-up grid does -->
123
+ <v-row no-gutters>
124
+ <v-col
125
+ v-for="(channel, index) in category.channels"
126
+ :key="channel.key"
127
+ cols="12"
128
+ :class="index < category.channels.length - 1 ? 'border-b-thin' : ''"
129
+ >
130
+ <div
131
+ class="d-flex align-center justify-space-between py-2"
132
+ :class="{ 'channel-unavailable': !channel.supported }"
133
+ >
134
+ <div class="pr-4">
135
+ <div class="text-body-1">{{ channel.label }}</div>
136
+ <div v-if="!channel.supported" class="text-caption mt-1">
137
+ {{ channel.note }}
138
+ </div>
139
+ </div>
140
+
141
+ <v-switch
142
+ :model-value="channel.enabled"
143
+ :disabled="!channel.supported || saving"
144
+ color="primary"
145
+ hide-details
146
+ density="compact"
147
+ :aria-label="`${channel.label} for ${category.label}`"
148
+ @update:model-value="
149
+ (value) => onToggle(category, channel, !!value)
150
+ "
151
+ />
152
+ </div>
153
+ </v-col>
154
+ </v-row>
155
+ </v-expansion-panel-text>
156
+ </v-expansion-panel>
157
+ </v-expansion-panels>
158
+ </v-col>
159
+ </template>
160
+ </v-row>
161
+ </v-col>
162
+
163
+ <!-- switching a safety alert off: say plainly what stops arriving -->
164
+ <v-dialog v-model="confirmOpen" max-width="460" persistent>
165
+ <v-card rounded="lg" class="pa-2">
166
+ <v-card-title class="text-h6">{{ confirmText.title }}</v-card-title>
167
+ <v-card-text class="text-body-1">{{ confirmText.body }}</v-card-text>
168
+ <v-card-actions class="justify-end">
169
+ <v-btn variant="text" class="text-none" @click="cancelConfirm">
170
+ {{ confirmText.cancel }}
171
+ </v-btn>
172
+ <v-btn
173
+ color="primary"
174
+ variant="tonal"
175
+ class="text-none"
176
+ @click="acceptConfirm"
177
+ >
178
+ {{ confirmText.confirm }}
179
+ </v-btn>
180
+ </v-card-actions>
181
+ </v-card>
182
+ </v-dialog>
183
+
184
+ <v-snackbar v-model="noticeOpen" :timeout="4000" location="bottom">
185
+ {{ notice }}
186
+ </v-snackbar>
187
+ </v-row>
188
+ </template>
189
+
190
+ <script setup lang="ts">
191
+ import type {
192
+ TNotificationCategorySetting,
193
+ TNotificationChannelSetting,
194
+ } from "../composables/useNotificationPreference";
195
+
196
+ const { getPreferences, savePreferences } = useNotificationPreference();
197
+
198
+ const categories = ref<TNotificationCategorySetting[]>([]);
199
+ const loading = ref(true);
200
+ const saving = ref(false);
201
+ const loadError = ref("");
202
+ const notice = ref("");
203
+ const noticeOpen = ref(false);
204
+
205
+ const confirmOpen = ref(false);
206
+ const confirmText = ref({ title: "", body: "", confirm: "", cancel: "" });
207
+ let confirmAccept: (() => void) | null = null;
208
+
209
+ /** the channels a person can actually move on this category */
210
+ const switchableIn = (category: TNotificationCategorySetting) =>
211
+ category.channels.filter((c) => c.supported);
212
+
213
+ const categoryAllOn = (category: TNotificationCategorySetting) =>
214
+ switchableIn(category).every((c) => c.enabled);
215
+
216
+ const allOn = computed(
217
+ () =>
218
+ categories.value.length > 0 &&
219
+ categories.value.every((category) => categoryAllOn(category)),
220
+ );
221
+
222
+ /** what is on, in words — so a collapsed panel still says what it is doing */
223
+ function summaryFor(category: TNotificationCategorySetting) {
224
+ const on = switchableIn(category).filter((c) => c.enabled);
225
+ if (!on.length) return "Off";
226
+ if (on.length === switchableIn(category).length) return "On everywhere";
227
+ return `On: ${on.map((c) => c.label).join(", ")}`;
228
+ }
229
+
230
+ const everythingSummary = computed(() => {
231
+ const total = categories.value.length;
232
+ const on = categories.value.filter((c) => categoryAllOn(c)).length;
233
+ if (on === total) return "Everything is on.";
234
+ if (on === 0) return "Everything is off.";
235
+ return `${on} of ${total} fully on.`;
236
+ });
237
+
238
+ async function load() {
239
+ loading.value = true;
240
+ loadError.value = "";
241
+ try {
242
+ const res = await getPreferences();
243
+ categories.value = res.data.categories;
244
+ } catch {
245
+ loadError.value =
246
+ "We could not load your notification settings. Please refresh the page and try again.";
247
+ } finally {
248
+ loading.value = false;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Save the whole screen after any change, and put it back the way it was if the
254
+ * save fails — so what is on screen is never a setting the server did not take.
255
+ */
256
+ async function persist(previous: TNotificationCategorySetting[]) {
257
+ saving.value = true;
258
+ try {
259
+ const res = await savePreferences({ channels: [], categories: categories.value });
260
+ categories.value = res.data.categories;
261
+ notice.value = "Saved.";
262
+ } catch {
263
+ categories.value = previous;
264
+ notice.value = "We could not save that change. Please try again.";
265
+ } finally {
266
+ noticeOpen.value = true;
267
+ saving.value = false;
268
+ }
269
+ }
270
+
271
+ const snapshot = () =>
272
+ JSON.parse(JSON.stringify(categories.value)) as TNotificationCategorySetting[];
273
+
274
+ function apply(mutate: () => void) {
275
+ const previous = snapshot();
276
+ mutate();
277
+ void persist(previous);
278
+ }
279
+
280
+ function onToggle(
281
+ category: TNotificationCategorySetting,
282
+ channel: TNotificationChannelSetting,
283
+ value: boolean,
284
+ ) {
285
+ if (!channel.supported) return;
286
+
287
+ const commit = () => apply(() => (channel.enabled = value));
288
+
289
+ // switching a safety alert OFF is the only thing worth stopping to confirm
290
+ if (category.safety && !value) {
291
+ confirmText.value = safetyOffConfirmation(category.label, channel.label);
292
+ confirmAccept = commit;
293
+ confirmOpen.value = true;
294
+ return;
295
+ }
296
+
297
+ commit();
298
+ }
299
+
300
+ function setCategory(category: TNotificationCategorySetting, value: boolean) {
301
+ const commit = () =>
302
+ apply(() => {
303
+ for (const channel of switchableIn(category)) channel.enabled = value;
304
+ });
305
+
306
+ if (category.safety && !value) {
307
+ confirmText.value = safetyOffConfirmation(category.label, "every channel");
308
+ confirmAccept = commit;
309
+ confirmOpen.value = true;
310
+ return;
311
+ }
312
+
313
+ commit();
314
+ }
315
+
316
+ function setAll(value: boolean) {
317
+ const commit = () =>
318
+ apply(() => {
319
+ for (const category of categories.value) {
320
+ for (const channel of switchableIn(category)) channel.enabled = value;
321
+ }
322
+ });
323
+
324
+ if (!value && categories.value.some((c) => c.safety)) {
325
+ confirmText.value = safetyOffConfirmation("safety and security", "every channel");
326
+ confirmAccept = commit;
327
+ confirmOpen.value = true;
328
+ return;
329
+ }
330
+
331
+ commit();
332
+ }
333
+
334
+ function acceptConfirm() {
335
+ confirmOpen.value = false;
336
+ confirmAccept?.();
337
+ confirmAccept = null;
338
+ }
339
+
340
+ function cancelConfirm() {
341
+ confirmOpen.value = false;
342
+ confirmAccept = null;
343
+ }
344
+
345
+ onMounted(load);
346
+ </script>
347
+
348
+ <style scoped>
349
+ /* Unavailable channels are shown and explained, never hidden.
350
+ *
351
+ * They are NOT faded. The disabled switch already says the control cannot be
352
+ * moved, and the sentence beside it is the only place a person is told why —
353
+ * dimming that sentence is dimming the explanation. A blanket opacity here
354
+ * measured 2.3:1 against the card, which is unreadable; the row is now marked
355
+ * by a rule down its edge instead, and every word stays at full strength. */
356
+ .channel-unavailable {
357
+ border-left: 2px solid rgb(var(--v-border-color));
358
+ padding-left: 12px;
359
+ opacity: 1;
360
+ }
361
+ </style>
@@ -22,7 +22,7 @@
22
22
  rounded="lg"
23
23
  :loading="loading"
24
24
  >
25
- <v-toolbar density="compact" color="grey-lighten-4">
25
+ <v-toolbar density="compact" class="px-3 border-b-sm" color="transparent">
26
26
  <template #prepend>
27
27
  <v-btn
28
28
  fab
@@ -0,0 +1,43 @@
1
+ <!--
2
+ THE PAGE TITLE ROW.
3
+
4
+ The design opens EVERY screen with this row: the page's name at 22px/800,
5
+ and the screen's primary action on the right of the same line. The product
6
+ has never had it - the page name lived only in the browser tab and in the
7
+ navigation rail - which is the single most visible difference between the
8
+ staging site and the handoff.
9
+
10
+ It is a label and a slot. It fetches nothing, decides nothing and navigates
11
+ nowhere; the action passed into it is the screen's own button.
12
+ -->
13
+ <template>
14
+ <div class="app-page-head">
15
+ <!-- A caller with actions but no page name (the shared table card, when the
16
+ screen has not given it a title) gets just the action group, at the
17
+ left, rather than an empty heading holding a line of blank space. -->
18
+ <div v-if="title || $slots.title || subtitle || $slots.subtitle">
19
+ <h1 class="app-page-head__title">
20
+ <slot name="title">{{ title }}</slot>
21
+ </h1>
22
+
23
+ <!-- The design's second line: a location, a count, a date range. Only
24
+ drawn when a screen supplies one. -->
25
+ <div v-if="subtitle || $slots.subtitle" class="app-page-head__sub">
26
+ <v-icon v-if="subtitleIcon" :icon="subtitleIcon" size="15" />
27
+ <slot name="subtitle">{{ subtitle }}</slot>
28
+ </div>
29
+ </div>
30
+
31
+ <div v-if="$slots.actions" class="app-page-head__actions">
32
+ <slot name="actions" />
33
+ </div>
34
+ </div>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ defineProps({
39
+ title: { type: String, default: "" },
40
+ subtitle: { type: String, default: "" },
41
+ subtitleIcon: { type: String, default: "" },
42
+ });
43
+ </script>