@7365admin1/layer-common 3.2.2-staging.81 → 3.2.2-staging.82

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.
@@ -1,54 +1,87 @@
1
1
  <template>
2
- <v-app-bar scroll-behavior="elevate" scroll-threshold="200">
2
+ <!--
3
+ PHASE 2 - THE 58px TOP BAR.
4
+
5
+ Same bar, same hamburger, same theme toggle, same profile menu with the
6
+ same two actions. What changes is its height (the design's 58px), the
7
+ breadcrumb it now carries, and the shape of the menu it opens.
8
+
9
+ The breadcrumb READS state that is already in the page - the site the
10
+ application has loaded and the route the router is on. It fetches nothing
11
+ and navigates nowhere; it is a label.
12
+ -->
13
+ <v-app-bar
14
+ :height="58"
15
+ flat
16
+ scroll-behavior="elevate"
17
+ scroll-threshold="200"
18
+ class="app-topbar-shell"
19
+ >
3
20
  <v-app-bar-nav-icon v-if="!props.hideNavIcon" @click="drawer = !drawer"></v-app-bar-nav-icon>
4
21
 
22
+ <div class="app-topbar-shell__crumb">
23
+ <span v-if="siteName" class="app-topbar-shell__site">{{ siteName }}</span>
24
+ <span v-if="siteName && moduleName" class="app-topbar-shell__sep">/</span>
25
+ <span class="app-topbar-shell__module">{{ moduleName }}</span>
26
+ </div>
27
+
5
28
  <template #append>
6
29
  <!--
7
30
  The icon now says which theme you would GET, and the choice is
8
31
  remembered - see `composables/useThemePreference.ts`. Same button, same
9
32
  place, same one click.
10
33
  -->
34
+ <!-- The design's 36px bordered icon button. Same one click, same icon. -->
11
35
  <v-btn
12
36
  :icon="current === 'dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"
13
37
  :aria-label="current === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
14
38
  variant="text"
15
- class="mx-2"
39
+ size="36"
40
+ class="app-topbar-shell__icon-btn ms-2"
16
41
  @click="toggleTheme"
17
42
  />
18
43
 
19
44
  <v-menu offset="10px" :close-on-content-click="false">
20
45
  <template #activator="{ props }">
21
- <v-btn fab variant="text" icon v-bind="props" class="mx-2">
46
+ <v-btn fab variant="text" icon v-bind="props" class="ms-2 me-1">
22
47
  <AvatarMain :image-src="currentUser?.profile" :name="name" />
23
48
  </v-btn>
24
49
  </template>
25
50
 
26
- <v-card width="350" max-height="600px" elevation="2" rounded="xl" class="pa-4">
27
- <v-row no-gutters>
28
- <v-col cols="12">
29
- <v-row no-gutters justify="center">
30
- <AvatarMain :image-src="currentUser?.profile" :name="name" />
31
- </v-row>
32
- </v-col>
33
-
34
- <v-col cols="12" class="text-center mt-2 mb-4">
35
- {{ name }}
36
- </v-col>
37
-
38
- <v-col cols="12" class="mb-3">
39
- <v-btn v-if="APP_NAME.toLowerCase() !== 'account'" block rounded="xl" variant="tonal" size="x-large"
40
- class="text-none text-subtitle-1 font-weight-regular" @click="redirect(APP_ACCOUNT, 'home')">
41
- Manage Account
42
- </v-btn>
43
- </v-col>
44
-
45
- <v-col cols="12">
46
- <v-btn block rounded="xl" variant="tonal" size="x-large"
47
- class="text-none text-subtitle-1 font-weight-regular" @click="logout()">
48
- Logout
49
- </v-btn>
50
- </v-col>
51
- </v-row>
51
+ <!--
52
+ 240px, 14px radius and the design's menu shadow. The two actions and
53
+ the condition on the first are exactly as they were.
54
+ -->
55
+ <v-card :width="240" elevation="0" class="app-profile-menu">
56
+ <div class="app-profile-menu__head">
57
+ <AvatarMain :image-src="currentUser?.profile" :name="name" />
58
+
59
+ <div class="app-profile-menu__who">
60
+ <div class="app-profile-menu__name">{{ name }}</div>
61
+ <div v-if="siteName" class="app-profile-menu__site">{{ siteName }}</div>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="app-profile-menu__rows">
66
+ <button
67
+ v-if="APP_NAME.toLowerCase() !== 'account'"
68
+ type="button"
69
+ class="app-profile-menu__row"
70
+ @click="redirect(APP_ACCOUNT, 'home')"
71
+ >
72
+ <v-icon icon="mdi-account-cog-outline" size="18" />
73
+ Manage Account
74
+ </button>
75
+
76
+ <button
77
+ type="button"
78
+ class="app-profile-menu__row app-profile-menu__row--danger"
79
+ @click="logout()"
80
+ >
81
+ <v-icon icon="mdi-logout" size="18" />
82
+ Logout
83
+ </button>
84
+ </div>
52
85
  </v-card>
53
86
  </v-menu>
54
87
  </template>
@@ -83,6 +116,55 @@ const { current, toggleTheme } = useThemePreference();
83
116
 
84
117
  const { currentUser } = useLocalAuth();
85
118
 
119
+ /**
120
+ * THE BREADCRUMB: `Site name / Current module`.
121
+ *
122
+ * Both halves are read from what the page already holds - the shared `site`
123
+ * state the applications populate, and the router's own current route. NO
124
+ * request is made from this component; if the site has not been loaded on a
125
+ * given screen the site half is simply omitted and the module stands alone.
126
+ */
127
+ const { site } = useSite();
128
+
129
+ const siteName = computed(() => String(site.value?.name ?? "").trim());
130
+
131
+ const route = useRoute();
132
+
133
+ /**
134
+ * The module's name comes off the route name, which the applications build
135
+ * from their own folder structure: `org-site-work-orders` is Work Orders.
136
+ * Leading parameter segments (`org`, `site`) are skipped and the name stops at
137
+ * the first one that follows, so a detail route (`...-feedbacks-id`) and a
138
+ * status route (`...-members-status`) both name their module rather than their
139
+ * argument.
140
+ */
141
+ const ACRONYMS: Record<string, string> = {
142
+ cctv: "CCTV",
143
+ hid: "HID",
144
+ dob: "DOB",
145
+ anpr: "ANPR",
146
+ nfc: "NFC",
147
+ qr: "QR",
148
+ soa: "SOA",
149
+ sp: "SP",
150
+ id: "ID",
151
+ };
152
+
153
+ const moduleName = computed(() => {
154
+ const segments = String(route.name ?? "").split("-").filter(Boolean);
155
+ const params = new Set(Object.keys(route.params ?? {}));
156
+
157
+ let i = 0;
158
+ while (i < segments.length && params.has(segments[i])) i++;
159
+
160
+ const words: string[] = [];
161
+ while (i < segments.length && !params.has(segments[i])) words.push(segments[i++]);
162
+
163
+ return words
164
+ .map((w) => ACRONYMS[w] ?? w.charAt(0).toUpperCase() + w.slice(1))
165
+ .join(" ");
166
+ });
167
+
86
168
  const profile = computed(() => {
87
169
  return `/api/public/${currentUser.value?.profile}`;
88
170
  });
@@ -116,6 +198,151 @@ const { getNameInitials } = useUtils();
116
198
  </script>
117
199
 
118
200
  <style scoped>
201
+ /* ---------------------------------------------------------------- */
202
+ /* The top bar. */
203
+ /* ---------------------------------------------------------------- */
204
+
205
+ /**
206
+ * The design's bar is 58px INCLUDING its rule. A real `border-bottom` is added
207
+ * to the 58px Vuetify lays out, so the bar measures 59 and every page below it
208
+ * starts a pixel low; an inset shadow draws the same 1px line inside the 58.
209
+ */
210
+ .app-topbar-shell,
211
+ .app-profile-menu {
212
+ /* The shell is the first surface to wear the design's typeface - see the
213
+ note in NavigationDrawer.vue. */
214
+ font-family: var(--font-sans);
215
+ }
216
+
217
+ .app-topbar-shell {
218
+ box-shadow: inset 0 -1px 0 var(--border);
219
+ }
220
+
221
+ .app-topbar-shell__crumb {
222
+ flex: 1 1 auto;
223
+ min-width: 0;
224
+ display: flex;
225
+ align-items: center;
226
+ gap: 6px;
227
+ font-size: var(--fs-breadcrumb);
228
+ /* Truncates rather than wraps: two lines would break a 58px bar, and the
229
+ site name is the half worth losing first. */
230
+ overflow: hidden;
231
+ white-space: nowrap;
232
+ }
233
+
234
+ .app-topbar-shell__site,
235
+ .app-topbar-shell__sep {
236
+ color: var(--muted);
237
+ overflow: hidden;
238
+ text-overflow: ellipsis;
239
+ }
240
+
241
+ .app-topbar-shell__module {
242
+ color: var(--text);
243
+ font-weight: 700;
244
+ flex: 0 0 auto;
245
+ }
246
+
247
+ /**
248
+ * THE TOP-BAR AVATAR is the design's accent-soft circle with accent-text
249
+ * initials, and only this one. `AvatarMain` gives every person a colour of
250
+ * their own from their name, which is right in a list of people - it is how
251
+ * you tell two rows apart - and wrong for the one avatar that is always the
252
+ * same person. `!important` because that per-person colour arrives as an
253
+ * inline style, and the rule is confined to the bar and its own menu.
254
+ */
255
+ .app-topbar-shell :deep(.v-avatar),
256
+ .app-profile-menu :deep(.v-avatar) {
257
+ background-color: var(--accent-soft) !important;
258
+ /* The initials' colour arrives inline as well, and in dark mode it is the
259
+ per-person colour ON a blue wash: 1.26:1, invisible. */
260
+ color: var(--accent-text) !important;
261
+ font-weight: 800;
262
+ }
263
+
264
+ /* 36px, 1px border, 10px radius - the design's icon button. */
265
+ .app-topbar-shell__icon-btn {
266
+ border: 1px solid var(--border);
267
+ border-radius: var(--r-inner);
268
+ }
269
+
270
+ /* ---------------------------------------------------------------- */
271
+ /* The 240px profile menu. */
272
+ /* ---------------------------------------------------------------- */
273
+
274
+ .app-profile-menu {
275
+ border-radius: var(--r-menu);
276
+ box-shadow: var(--shadow-menu);
277
+ border: 1px solid var(--border);
278
+ overflow: hidden;
279
+ }
280
+
281
+ .app-profile-menu__head {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 10px;
285
+ padding: 14px;
286
+ border-bottom: 1px solid var(--border);
287
+ }
288
+
289
+ .app-profile-menu__who {
290
+ min-width: 0;
291
+ }
292
+
293
+ .app-profile-menu__name {
294
+ font-size: 13.5px;
295
+ font-weight: 800;
296
+ line-height: 1.3;
297
+ color: var(--text);
298
+ overflow: hidden;
299
+ white-space: nowrap;
300
+ text-overflow: ellipsis;
301
+ }
302
+
303
+ .app-profile-menu__site {
304
+ font-size: 11.5px;
305
+ font-weight: 600;
306
+ line-height: 1.3;
307
+ color: var(--muted);
308
+ overflow: hidden;
309
+ white-space: nowrap;
310
+ text-overflow: ellipsis;
311
+ }
312
+
313
+ .app-profile-menu__rows {
314
+ padding: 6px;
315
+ }
316
+
317
+ .app-profile-menu__row {
318
+ width: 100%;
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 10px;
322
+ padding: 9px 10px;
323
+ border-radius: var(--r-inner-sm);
324
+ font-size: var(--fs-btn);
325
+ font-weight: var(--fw-btn);
326
+ color: var(--text2);
327
+ text-align: start;
328
+ cursor: pointer;
329
+ background: transparent;
330
+ }
331
+
332
+ .app-profile-menu__row:hover {
333
+ background: var(--hover);
334
+ }
335
+
336
+ /* Logout is the design's red row: the colour lives on the label, and the
337
+ soft error background is what the hover paints. */
338
+ .app-profile-menu__row--danger {
339
+ color: var(--err);
340
+ }
341
+
342
+ .app-profile-menu__row--danger:hover {
343
+ background: var(--err-bg);
344
+ }
345
+
119
346
  .APP_NAME {
120
347
  display: inline-block;
121
348
  word-wrap: break-word;
@@ -45,45 +45,73 @@
45
45
  Nothing about the menu changes - same items, same order, same permissions,
46
46
  same routes. Only where the panel is drawn.
47
47
  -->
48
+ <!--
49
+ PHASE 2 - THE GROUPED SIDEBAR.
50
+
51
+ The rail is 252px as drawn, the brand row is the design's 34px tile plus
52
+ the product name, and the menu is printed in labelled sections.
53
+
54
+ The MENU ITSELF IS UNTOUCHED. Each application still builds its own array,
55
+ gated item by item on its own permissions, and it is rendered in exactly
56
+ that order; `utils/nav-sections.ts` only decides which label to print above
57
+ which item, and an item whose title it does not know keeps its place under
58
+ the heading it follows. Nothing here can add, hide, move or rename a menu
59
+ entry, and no route, permission or click handler is involved.
60
+ -->
48
61
  <v-navigation-drawer
49
62
  v-model="drawer"
50
63
  :mobile-breakpoint="1024"
64
+ :width="252"
51
65
  class="bg-brand-surface brand-drawer"
52
66
  >
53
- <v-list>
54
- <v-list-item>
55
- <v-list-item-title class="text-h6">
56
- {{ props.title || APP_NAME }}
57
- </v-list-item-title>
58
- </v-list-item>
67
+ <div class="brand-drawer__brand">
68
+ <div class="brand-drawer__logo">
69
+ <v-icon :icon="brandIcon" size="19" />
70
+ </div>
59
71
 
60
- <slot name="action" />
72
+ <div class="brand-drawer__names">
73
+ <div class="brand-drawer__app">{{ props.title || APP_NAME }}</div>
74
+ <div class="brand-drawer__suite">iService 365</div>
75
+ </div>
76
+ </div>
77
+
78
+ <v-list>
79
+ <!--
80
+ The site selector, in the design's bordered card. The slot's CONTENTS
81
+ are whatever the application puts there, still inside the same
82
+ `v-list` it has always been inside - this only draws the box around it.
83
+ -->
84
+ <div v-if="$slots.action" class="brand-drawer__context">
85
+ <slot name="action" />
86
+ </div>
61
87
 
62
88
  <!-- NAVIGATION ITEMS -->
63
- <v-tooltip
64
- v-for="(item, index) in navigationItems"
89
+ <template
90
+ v-for="({ item, section }, index) in sectionedItems"
65
91
  :key="`${item.title}-${index}`"
66
- location="right"
67
- :disabled="!item.disabled"
68
92
  >
69
- <template #activator="{ props: tooltipProps }">
70
- <div v-bind="tooltipProps">
71
- <NavigationItem
72
- :title="item.title"
73
- :icon="item.icon"
74
- :route="item.route"
75
- :children="item.children"
76
- :link="item.link"
77
- :class="{
78
- 'opacity-50 pointer-events-none': item.disabled,
79
- }"
80
- @click="() => handleClick(item)"
81
- />
82
- </div>
83
- </template>
84
-
85
- <span>Please complete onboarding first</span>
86
- </v-tooltip>
93
+ <div v-if="section" class="brand-drawer__section">{{ section }}</div>
94
+
95
+ <v-tooltip location="right" :disabled="!item.disabled">
96
+ <template #activator="{ props: tooltipProps }">
97
+ <div v-bind="tooltipProps">
98
+ <NavigationItem
99
+ :title="item.title"
100
+ :icon="item.icon"
101
+ :route="item.route"
102
+ :children="item.children"
103
+ :link="item.link"
104
+ :class="{
105
+ 'opacity-50 pointer-events-none': item.disabled,
106
+ }"
107
+ @click="() => handleClick(item)"
108
+ />
109
+ </div>
110
+ </template>
111
+
112
+ <span>Please complete onboarding first</span>
113
+ </v-tooltip>
114
+ </template>
87
115
 
88
116
  <!-- SERVICES SLOT -->
89
117
  <slot name="services" />
@@ -94,6 +122,8 @@
94
122
  <script setup lang="ts">
95
123
  import { useDisplay } from "vuetify";
96
124
 
125
+ import { withSections } from "../../utils/nav-sections";
126
+
97
127
  const props = defineProps({
98
128
  navigationItems: { type: Array, required: true },
99
129
  title: { type: String, default: "" },
@@ -163,6 +193,39 @@ const navigationItems = computed(() => {
163
193
  return items;
164
194
  });
165
195
 
196
+ const sectionedItems = computed(() => withSections(navigationItems.value));
197
+
198
+ /**
199
+ * The design's brand tile carries the product's own mark. One icon for eleven
200
+ * applications would be one application's icon on ten of them, so the tile
201
+ * follows `APP_NAME` - the same value the row already prints - and falls back
202
+ * to the suite's shield for anything not listed.
203
+ */
204
+ const BRAND_ICONS: Record<string, string> = {
205
+ security: "mdi-shield-check",
206
+ "property-management": "mdi-office-building",
207
+ hygiene: "mdi-broom",
208
+ landscape: "mdi-flower",
209
+ "pest-control": "mdi-bug",
210
+ "pool-mgmt": "mdi-pool",
211
+ "mechanical-electrical": "mdi-flash",
212
+ admin: "mdi-shield-crown",
213
+ org: "mdi-domain",
214
+ account: "mdi-account-circle",
215
+ main: "mdi-view-grid",
216
+ };
217
+
218
+ const brandIcon = computed(() => {
219
+ // `APP_NAME` is an environment value and is spelled for people ("Property
220
+ // Management"), so it is normalised rather than matched literally.
221
+ const key = String(APP_NAME ?? "")
222
+ .toLowerCase()
223
+ .trim()
224
+ .replace(/[\s_]+/g, "-");
225
+
226
+ return BRAND_ICONS[key] ?? "mdi-shield-check";
227
+ });
228
+
166
229
  const handleClick = (item: any) => {
167
230
  if (item.disabled) return;
168
231
  emit("navigate", item);
@@ -175,5 +238,165 @@ const handleClick = (item: any) => {
175
238
  --v-border-opacity: 0.45;
176
239
  --v-hover-opacity: 0.08;
177
240
  --v-activated-opacity: 0.18;
241
+
242
+ /**
243
+ * Phase 1 loaded Manrope and named it `--font-sans` but deliberately set it
244
+ * on nothing: switching eleven applications to a new typeface before a
245
+ * single surface had been rebuilt would have left the product looking
246
+ * half-migrated. The shell IS the surface Phase 2 rebuilds, so it is the
247
+ * first thing to wear it.
248
+ */
249
+ font-family: var(--font-sans);
250
+ }
251
+
252
+ /* ---------------------------------------------------------------- */
253
+ /* The brand row: 34px accent tile + product name + suite name. */
254
+ /* ---------------------------------------------------------------- */
255
+
256
+ .brand-drawer__brand {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: 10px;
260
+ padding: 14px 16px 10px;
261
+ }
262
+
263
+ .brand-drawer__logo {
264
+ flex: 0 0 34px;
265
+ width: 34px;
266
+ height: 34px;
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: center;
270
+ border-radius: var(--r-inner);
271
+ /* A white glyph, so the fill has to be the one accent that carries white:
272
+ `--accent` is 3.23:1 under it, `--accent-strong` is 4.57:1. */
273
+ background: var(--accent-strong);
274
+ color: var(--on-accent-strong);
275
+ }
276
+
277
+ .brand-drawer__names {
278
+ min-width: 0;
279
+ }
280
+
281
+ .brand-drawer__app {
282
+ font-size: var(--fs-card-title);
283
+ font-weight: var(--fw-card-title);
284
+ line-height: 1.2;
285
+ color: var(--text);
286
+ overflow: hidden;
287
+ white-space: nowrap;
288
+ text-overflow: ellipsis;
289
+ text-transform: capitalize;
290
+ }
291
+
292
+ .brand-drawer__suite {
293
+ font-size: 11.5px;
294
+ font-weight: 600;
295
+ line-height: 1.3;
296
+ color: var(--muted);
297
+ }
298
+
299
+ /**
300
+ * The organisation / site selector, drawn as the design's bordered card.
301
+ *
302
+ * Two applications have no selector to draw: `web-app-admin` passes no slot at
303
+ * all (handled by the `v-if`), and `web-app-org`'s super-admin layout passes an
304
+ * EMPTY one - `<template #action> </template>` - which the `v-if` cannot see.
305
+ * An empty bordered box on that rail would be a box around nothing, so a card
306
+ * with no element inside it is not drawn.
307
+ */
308
+ .brand-drawer__context:not(:has(*)) {
309
+ display: none;
310
+ }
311
+
312
+ .brand-drawer__context {
313
+ margin: 2px 4px 4px;
314
+ border: 1px solid var(--border);
315
+ border-radius: var(--r-inner-lg);
316
+ background: var(--card);
317
+ overflow: hidden;
318
+ }
319
+
320
+ /* ---------------------------------------------------------------- */
321
+ /* Section labels and the menu items under them. */
322
+ /* ---------------------------------------------------------------- */
323
+
324
+ .brand-drawer__section {
325
+ padding: 14px 12px 6px;
326
+ font-size: var(--fs-section-label);
327
+ font-weight: var(--fw-section-label);
328
+ letter-spacing: var(--ls-section-label);
329
+ line-height: 1.2;
330
+ text-transform: uppercase;
331
+ color: var(--muted);
332
+ }
333
+
334
+ /* The first label sits directly under the site selector, so it does not need
335
+ the separating space the ones between two menu blocks do. */
336
+ .brand-drawer__section:first-child {
337
+ padding-top: 4px;
338
+ }
339
+
340
+ /**
341
+ * `:deep` because the items are `NavigationItem`s - a child component - and
342
+ * the design's item shape (10px radius, 8px 12px, 13px label) belongs to the
343
+ * rail rather than to that component, which is also rendered elsewhere.
344
+ *
345
+ * ONLY shape and type. The active state's colour still comes from
346
+ * `color="primary"` on the item itself, which is `--accent-text`.
347
+ */
348
+ .brand-drawer :deep(.v-list) {
349
+ padding: 0 8px 12px;
350
+ }
351
+
352
+ /**
353
+ * The two `!important`s are not carelessness: Vuetify puts `rounded-0` and
354
+ * `text-subtitle-2` ON the item, and both of those are utility classes that
355
+ * are themselves `!important`. Nothing weaker can reach the design's 10px
356
+ * radius or its 13px label. Both are confined to `.brand-drawer`.
357
+ */
358
+ .brand-drawer :deep(.v-list-item) {
359
+ border-radius: var(--r-inner) !important;
360
+ min-height: 38px;
361
+ padding-top: 8px;
362
+ padding-bottom: 8px;
363
+ padding-inline: 12px;
364
+ font-size: var(--fs-nav) !important;
365
+ }
366
+
367
+ .brand-drawer :deep(.v-list-item__prepend > .v-icon) {
368
+ font-size: 19px;
369
+ opacity: 1;
370
+ }
371
+
372
+ /* Vuetify's own 16px gap between the icon and the label, brought to the 10px
373
+ the design draws. The spacer element is what holds that distance - a margin
374
+ on the icon leaves it where it was. */
375
+ .brand-drawer :deep(.v-list-item__spacer) {
376
+ width: 10px;
377
+ }
378
+
379
+ .brand-drawer :deep(.v-list-item--active) {
380
+ font-weight: var(--fw-section-label);
381
+ }
382
+
383
+ /**
384
+ * The design's active item is a 12% accent wash, which is exactly what
385
+ * `--accent-soft` is. Vuetify paints the same idea with an overlay whose
386
+ * opacity is theme-wide, so the fill is stated here instead of leaving the
387
+ * rail's active row to a number tuned for tables and cards.
388
+ */
389
+ .brand-drawer :deep(.v-list-item--active > .v-list-item__overlay) {
390
+ opacity: 0;
391
+ }
392
+
393
+ .brand-drawer :deep(.v-list-item--active) {
394
+ background: var(--accent-soft);
395
+ }
396
+
397
+ /* A child row indents by the icon slot it does not have, so the labels of a
398
+ group line up with the labels of its siblings rather than with their icons. */
399
+ .brand-drawer :deep(.v-list-group__items .v-list-item) {
400
+ padding-inline-start: 41px !important;
178
401
  }
179
402
  </style>
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.81",
5
+ "version": "3.2.2-staging.82",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -0,0 +1,131 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { NAV_SECTIONS, withSections } from "./nav-sections.ts";
5
+
6
+ /** The Security menu, item for item and in `web-app-security`'s own order. */
7
+ const SECURITY = [
8
+ "Dashboard",
9
+ "Feedbacks",
10
+ "Work Orders",
11
+ "Vehicle Mgmt",
12
+ "Building Mgmt",
13
+ "Visitor Mgmt",
14
+ "Pass & Key Mgmt",
15
+ "HID Face Reader",
16
+ "Daily Occurrence Books",
17
+ "Manpower",
18
+ "Virtual Patrol",
19
+ "Robot Mgmt",
20
+ "Incident Reports",
21
+ "Bulletin Board",
22
+ "Attendance",
23
+ "My Attendance",
24
+ "Invitations",
25
+ "Members",
26
+ "Roles & Permissions",
27
+ "Settings",
28
+ ].map((title) => ({ title }));
29
+
30
+ /**
31
+ * THE RULE THAT MATTERS. A label is decoration; the menu is the product. If
32
+ * this ever fails, someone has taught the sidebar to reorder itself.
33
+ */
34
+ test("the menu comes back in the order it went in, item for item", () => {
35
+ const out = withSections(SECURITY);
36
+
37
+ assert.equal(out.length, SECURITY.length);
38
+ out.forEach((entry, i) => {
39
+ assert.equal(entry.item, SECURITY[i], `position ${i}`);
40
+ });
41
+ });
42
+
43
+ test("Security is labelled the way the design draws it", () => {
44
+ const labelled = withSections(SECURITY)
45
+ .filter((e) => e.section)
46
+ .map((e) => `${e.section}: ${e.item.title}`);
47
+
48
+ assert.deepEqual(labelled, [
49
+ "OVERVIEW: Dashboard",
50
+ "SERVICE DESK: Feedbacks",
51
+ "PROPERTY: Vehicle Mgmt",
52
+ "SECURITY OPERATIONS: HID Face Reader",
53
+ "COMMUNITY: Bulletin Board",
54
+ "WORKFORCE: Attendance",
55
+ "ADMINISTRATION: Invitations",
56
+ ]);
57
+ });
58
+
59
+ /**
60
+ * A permission-gated menu is a SUBSET, and every application's is different.
61
+ * Dropping the items a user cannot see must not leave a heading behind or move
62
+ * one onto the wrong item.
63
+ */
64
+ test("a user who can only see three modules gets three correct headings", () => {
65
+ const out = withSections(
66
+ [{ title: "Dashboard" }, { title: "Work Orders" }, { title: "Settings" }]
67
+ );
68
+
69
+ assert.deepEqual(
70
+ out.map((e) => e.section),
71
+ ["OVERVIEW", "SERVICE DESK", "ADMINISTRATION"]
72
+ );
73
+ });
74
+
75
+ /**
76
+ * The eleven applications add menu items without touching this layer. An
77
+ * unknown title must appear where its author put it, under the heading it
78
+ * follows - never suppressed, and never given a heading of its own.
79
+ */
80
+ test("an unmapped module inherits the section above it", () => {
81
+ const out = withSections([
82
+ { title: "Dashboard" },
83
+ { title: "Something New" },
84
+ { title: "Members" },
85
+ ]);
86
+
87
+ assert.deepEqual(
88
+ out.map((e) => e.section),
89
+ ["OVERVIEW", "", "ADMINISTRATION"]
90
+ );
91
+ assert.equal(out[1].item.title, "Something New");
92
+ });
93
+
94
+ test("an unmapped FIRST item prints no heading rather than a blank one", () => {
95
+ const out = withSections([{ title: "Something New" }, { title: "Members" }]);
96
+
97
+ assert.deepEqual(
98
+ out.map((e) => e.section),
99
+ ["", "ADMINISTRATION"]
100
+ );
101
+ });
102
+
103
+ /**
104
+ * The applications do not agree on order: Landscape lists Feedbacks after its
105
+ * equipment modules. The heading is printed again rather than left off, so no
106
+ * item ever sits under a heading that is not its own.
107
+ */
108
+ test("a section that comes back later is labelled again, not orphaned", () => {
109
+ const out = withSections([
110
+ { title: "Feedbacks" },
111
+ { title: "Equipment Items" },
112
+ { title: "Work Orders" },
113
+ ]);
114
+
115
+ assert.deepEqual(
116
+ out.map((e) => e.section),
117
+ ["SERVICE DESK", "EQUIPMENT", "SERVICE DESK"]
118
+ );
119
+ });
120
+
121
+ test("a missing or empty title does not throw", () => {
122
+ const out = withSections([{}, { title: "" }] as Array<{ title?: string }>);
123
+ assert.equal(out.length, 2);
124
+ });
125
+
126
+ /** Every section name is the uppercase form the type scale is drawn for. */
127
+ test("section names are uppercase", () => {
128
+ for (const section of Object.values(NAV_SECTIONS)) {
129
+ assert.equal(section, section.toUpperCase(), section);
130
+ }
131
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * THE GROUPED SIDEBAR - section labels, without touching the menu.
3
+ *
4
+ * The design draws the sidebar in labelled sections (OVERVIEW, SERVICE DESK,
5
+ * PROPERTY, SECURITY OPERATIONS, COMMUNITY, WORKFORCE, ADMINISTRATION). Each
6
+ * application builds its own menu array in its `layouts/default.vue`, gated
7
+ * item by item on permissions, and that array is the product's navigation.
8
+ *
9
+ * So this file adds a LABEL and nothing else:
10
+ *
11
+ * - the order of the menu is the order the application gave us. Nothing is
12
+ * moved, sorted, merged, hidden or renamed. A label is inserted when the
13
+ * section CHANGES as we walk that existing order, which means the label is
14
+ * always true of the items under it.
15
+ * - a title this map has never heard of inherits the section above it, so a
16
+ * new menu item added by any of the eleven applications appears exactly
17
+ * where its author put it, under the heading it follows, rather than
18
+ * disappearing or forcing a stray heading.
19
+ * - the first item always gets its section printed, even if that section is
20
+ * "" (nothing) - a leading blank label is simply not rendered.
21
+ *
22
+ * A section may therefore appear twice in one sidebar. That is deliberate: the
23
+ * applications do not all list their modules in the same order (Landscape puts
24
+ * Feedbacks after Equipment; Property Management interleaves five sections),
25
+ * and printing the heading again is honest, where suppressing it would leave a
26
+ * run of items sitting under a heading that is not theirs.
27
+ */
28
+
29
+ export const NAV_SECTIONS: Record<string, string> = {
30
+ // OVERVIEW
31
+ Dashboard: "OVERVIEW",
32
+ Home: "OVERVIEW",
33
+
34
+ // SERVICE DESK
35
+ "Service Providers": "SERVICE DESK",
36
+ Feedbacks: "SERVICE DESK",
37
+ "Work Orders": "SERVICE DESK",
38
+ "Online Forms": "SERVICE DESK",
39
+
40
+ // SERVICE DELIVERY - the five service applications' own working modules
41
+ "Cleaning Schedule": "SERVICE DELIVERY",
42
+ "Landscape Schedule": "SERVICE DELIVERY",
43
+ "Pest Schedule": "SERVICE DELIVERY",
44
+ "Pool Schedule": "SERVICE DELIVERY",
45
+ "Technician Schedule": "SERVICE DELIVERY",
46
+ Area: "SERVICE DELIVERY",
47
+ Unit: "SERVICE DELIVERY",
48
+ "Schedule Task": "SERVICE DELIVERY",
49
+
50
+ // EQUIPMENT
51
+ "Equipment Management": "EQUIPMENT",
52
+ "Equipment Items": "EQUIPMENT",
53
+
54
+ // PROPERTY
55
+ "Vehicle Mgmt": "PROPERTY",
56
+ "Building Mgmt": "PROPERTY",
57
+ "Visitor Mgmt": "PROPERTY",
58
+ "Pass & Key Mgmt": "PROPERTY",
59
+ "Facility Mgmt": "PROPERTY",
60
+ "People Mgmt": "PROPERTY",
61
+ "Document Mgmt": "PROPERTY",
62
+ "Access Mgmt": "PROPERTY",
63
+ "Event Mgmt": "PROPERTY",
64
+ "Emergency Contacts": "PROPERTY",
65
+ "Online Billing": "PROPERTY",
66
+ "SOA Mgmt": "PROPERTY",
67
+
68
+ // SECURITY OPERATIONS
69
+ "HID Face Reader": "SECURITY OPERATIONS",
70
+ "Daily Occurrence Books": "SECURITY OPERATIONS",
71
+ Manpower: "SECURITY OPERATIONS",
72
+ "Virtual Patrol": "SECURITY OPERATIONS",
73
+ "NFC Patrol": "SECURITY OPERATIONS",
74
+ "Robot Mgmt": "SECURITY OPERATIONS",
75
+ "Incident Reports": "SECURITY OPERATIONS",
76
+ CCTV: "SECURITY OPERATIONS",
77
+
78
+ // COMMUNITY
79
+ "Bulletin Board": "COMMUNITY",
80
+ "Bulletin Videos": "COMMUNITY",
81
+
82
+ // WORKFORCE
83
+ Attendance: "WORKFORCE",
84
+ "My Attendance": "WORKFORCE",
85
+
86
+ // ADMINISTRATION
87
+ Invitations: "ADMINISTRATION",
88
+ "SP Approvals": "ADMINISTRATION",
89
+ Members: "ADMINISTRATION",
90
+ "Roles & Permissions": "ADMINISTRATION",
91
+ Settings: "ADMINISTRATION",
92
+ "Site Settings": "ADMINISTRATION",
93
+ Organizations: "ADMINISTRATION",
94
+ Users: "ADMINISTRATION",
95
+ "Promo Codes": "ADMINISTRATION",
96
+
97
+ // ACCOUNT - `web-app-account` is a personal-settings application, not a site
98
+ "Personal Info": "ACCOUNT",
99
+ "Payment Methods": "ACCOUNT",
100
+
101
+ // SERVICES - the account application's list of the products a user can enter
102
+ "Property Management": "SERVICES",
103
+ Security: "SERVICES",
104
+ Hygiene: "SERVICES",
105
+ "Mechanical & Electrical": "SERVICES",
106
+ "Pest Control Services": "SERVICES",
107
+ "Landscaping Services": "SERVICES",
108
+ "Pool Maintenance Services": "SERVICES",
109
+ };
110
+
111
+ export type TSectionedNavItem<T> = { item: T; section: string };
112
+
113
+ /**
114
+ * Walks the menu IN THE ORDER GIVEN and tags each item with the section label
115
+ * to print above it, or "" for no label. Same length, same order, same objects.
116
+ */
117
+ export function withSections<T extends { title?: string }>(
118
+ items: T[]
119
+ ): Array<TSectionedNavItem<T>> {
120
+ let current = "";
121
+
122
+ return items.map((item, index) => {
123
+ const section = NAV_SECTIONS[String(item?.title ?? "")] ?? current;
124
+ const changed = index === 0 || section !== current;
125
+ current = section;
126
+ return { item, section: changed ? section : "" };
127
+ });
128
+ }