@7365admin1/layer-common 3.2.2-staging.81 → 3.2.2-staging.83
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/components/FormDialog.vue +60 -21
- package/components/Layout/Header.vue +256 -29
- package/components/Layout/NavigationDrawer.vue +252 -29
- package/components/StatusChip.vue +64 -0
- package/components/TableMain.vue +175 -17
- package/package.json +1 -1
- package/utils/nav-sections.test.ts +131 -0
- package/utils/nav-sections.ts +128 -0
|
@@ -1,31 +1,35 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
PHASE 3 - THE MODAL.
|
|
3
|
+
|
|
4
|
+
520px, 16px radius and the design's overlay and shadow, with the BODY as the
|
|
5
|
+
only thing that scrolls so the footer never leaves the screen. Same dialog,
|
|
6
|
+
same slots, same close behaviour, same `persistent`.
|
|
7
|
+
|
|
8
|
+
The two `theme.name.value === ...` branches are gone, and that is a fix, not
|
|
9
|
+
a preference: they hard-coded `bg-white` and Vuetify's `grey-darken-4`, so
|
|
10
|
+
the card was painted by a literal colour instead of by the theme it is in.
|
|
11
|
+
`--card` is that surface in each theme.
|
|
12
|
+
-->
|
|
1
13
|
<template>
|
|
2
|
-
<v-dialog v-model="model" max-width="
|
|
3
|
-
<v-card
|
|
4
|
-
class="px-3 d-flex flex-column"
|
|
5
|
-
:class="`${theme.name.value === 'light' ? 'bg-white' : ''} ${
|
|
6
|
-
mdAndUp ? 'rounded-xl' : ''
|
|
7
|
-
}`"
|
|
8
|
-
:style="{ border: 'none', boxShadow: 'none' }"
|
|
9
|
-
>
|
|
14
|
+
<v-dialog v-model="model" :max-width="520" persistent class="form-dialog">
|
|
15
|
+
<v-card class="d-flex flex-column form-dialog__card" :style="{ border: 'none' }">
|
|
10
16
|
<v-toolbar
|
|
11
|
-
|
|
12
|
-
class="flex-shrink-0
|
|
17
|
+
color="transparent"
|
|
18
|
+
class="flex-shrink-0 form-dialog__head"
|
|
13
19
|
style="position: sticky; top: 0; z-index: 10"
|
|
14
20
|
>
|
|
15
21
|
<slot name="title">
|
|
16
|
-
<span class="
|
|
17
|
-
Dialog
|
|
18
|
-
</span>
|
|
22
|
+
<span class="form-dialog__title"> Dialog </span>
|
|
19
23
|
</slot>
|
|
20
24
|
<v-spacer />
|
|
21
|
-
<v-btn icon="mdi-close" @click="closeDialog" />
|
|
25
|
+
<v-btn icon="mdi-close" variant="text" @click="closeDialog" />
|
|
22
26
|
</v-toolbar>
|
|
23
27
|
|
|
24
|
-
<v-sheet class="flex-grow-1 overflow-y-auto px-
|
|
28
|
+
<v-sheet class="flex-grow-1 overflow-y-auto px-4 form-dialog__body">
|
|
25
29
|
<slot />
|
|
26
30
|
</v-sheet>
|
|
27
31
|
|
|
28
|
-
<v-sheet class="flex-shrink-0 px-
|
|
32
|
+
<v-sheet class="flex-shrink-0 px-4 py-3 form-dialog__footer">
|
|
29
33
|
<slot name="footer" />
|
|
30
34
|
</v-sheet>
|
|
31
35
|
</v-card>
|
|
@@ -33,11 +37,6 @@
|
|
|
33
37
|
</template>
|
|
34
38
|
|
|
35
39
|
<script setup lang="ts">
|
|
36
|
-
import { useTheme, useDisplay } from "vuetify";
|
|
37
|
-
|
|
38
|
-
const theme = useTheme().global;
|
|
39
|
-
const { mdAndUp } = useDisplay();
|
|
40
|
-
|
|
41
40
|
const props = defineProps<{
|
|
42
41
|
modelValue: boolean;
|
|
43
42
|
}>();
|
|
@@ -62,4 +61,44 @@ function closeDialog() {
|
|
|
62
61
|
:deep(.v-dialog .v-overlay__content) {
|
|
63
62
|
margin: auto;
|
|
64
63
|
}
|
|
64
|
+
|
|
65
|
+
.form-dialog__card {
|
|
66
|
+
font-family: var(--font-sans);
|
|
67
|
+
background: var(--card);
|
|
68
|
+
/* `!important` because Vuetify's own `.v-overlay__content > .v-card` rule is
|
|
69
|
+
more specific than a class on the card, and it is what keeps the dialog at
|
|
70
|
+
its factory 4px. */
|
|
71
|
+
border-radius: var(--r-modal) !important;
|
|
72
|
+
box-shadow: var(--shadow-modal);
|
|
73
|
+
/* Never taller than the screen it has to fit inside; the body is what
|
|
74
|
+
scrolls, so the Cancel/Submit footer cannot be pushed out of view. */
|
|
75
|
+
max-height: calc(100vh - 32px);
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.form-dialog__head {
|
|
80
|
+
border-bottom: 1px solid var(--border);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.form-dialog__title {
|
|
84
|
+
font-size: var(--fs-card-title);
|
|
85
|
+
font-weight: var(--fw-card-title);
|
|
86
|
+
color: var(--text);
|
|
87
|
+
text-transform: capitalize;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.form-dialog__body,
|
|
91
|
+
.form-dialog__footer {
|
|
92
|
+
background: var(--card);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.form-dialog__footer {
|
|
96
|
+
border-top: 1px solid var(--border);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@media (max-width: 599.98px) {
|
|
100
|
+
.form-dialog__card {
|
|
101
|
+
max-height: calc(100dvh - 32px);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
65
104
|
</style>
|
|
@@ -1,54 +1,87 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
{{ name }}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
<
|
|
54
|
-
<
|
|
55
|
-
<v-
|
|
56
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
64
|
-
v-for="(item, index) in
|
|
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
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
THE STATUS CHIP.
|
|
3
|
+
|
|
4
|
+
A pill in the tone the design assigns to the status WORD, with the 6px
|
|
5
|
+
leading dot. The word -> tone mapping is `utils/status.ts` and is shared, so
|
|
6
|
+
"Open" is the same green on a work order and on a Daily Occurrence Book, and
|
|
7
|
+
a status the design never named comes out neutral rather than guessed.
|
|
8
|
+
|
|
9
|
+
Colour only. The chip prints the label it is given; it does not decide, map,
|
|
10
|
+
translate or shorten a status.
|
|
11
|
+
-->
|
|
12
|
+
<template>
|
|
13
|
+
<span class="status-chip" :class="[toneClass, { 'status-chip--plain': !dot }]">
|
|
14
|
+
<span v-if="dot" class="status-chip__dot" />
|
|
15
|
+
{{ label ?? status }}
|
|
16
|
+
</span>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { statusToneClass } from "../utils/status";
|
|
21
|
+
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
/** The status word - what decides the colour. */
|
|
24
|
+
status: { type: String, default: "" },
|
|
25
|
+
/** What to PRINT, when that differs from the word being matched on. */
|
|
26
|
+
label: { type: String, default: undefined },
|
|
27
|
+
/** The design's tag chips (pass IDs, role names) carry no leading dot. */
|
|
28
|
+
dot: { type: Boolean, default: true },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const toneClass = computed(() => statusToneClass(props.status));
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.status-chip {
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 6px;
|
|
39
|
+
padding: 4px 10px;
|
|
40
|
+
border-radius: var(--r-pill);
|
|
41
|
+
font-family: var(--font-sans);
|
|
42
|
+
font-size: var(--fs-chip-lg);
|
|
43
|
+
font-weight: var(--fw-chip);
|
|
44
|
+
line-height: 1.4;
|
|
45
|
+
white-space: nowrap;
|
|
46
|
+
/* `--tone-bg` is set alongside the colour by the `.tone-*` class - see
|
|
47
|
+
assets/css/tokens.css - so one class carries both halves of the pair the
|
|
48
|
+
contrast was measured on. */
|
|
49
|
+
background: var(--tone-bg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.status-chip--plain {
|
|
53
|
+
border-radius: var(--r-tag);
|
|
54
|
+
font-size: var(--fs-chip);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.status-chip__dot {
|
|
58
|
+
width: 6px;
|
|
59
|
+
height: 6px;
|
|
60
|
+
border-radius: var(--r-pill);
|
|
61
|
+
background: currentColor;
|
|
62
|
+
flex: 0 0 6px;
|
|
63
|
+
}
|
|
64
|
+
</style>
|
package/components/TableMain.vue
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
PHASE 3 - THE STANDARD TABLE CARD.
|
|
3
|
+
|
|
4
|
+
The redesign is, more than anything else, a table card: toolbar row (refresh,
|
|
5
|
+
optional in-card search, pagination) -> uppercase header row -> rows. This is
|
|
6
|
+
the shared one, so this file is where that pattern is drawn once.
|
|
7
|
+
|
|
8
|
+
VISUAL ONLY, and that matters more here than anywhere. Every column, filter,
|
|
9
|
+
sort, action, slot, prop, emit and page still behaves exactly as it did:
|
|
10
|
+
the headers are the caller's, the items are the caller's, every slot is still
|
|
11
|
+
forwarded to `v-data-table` untouched, and no prop's default has changed. The
|
|
12
|
+
empty state is the same wording `v-data-table` already showed, with the icon
|
|
13
|
+
the design draws above it - and it is only supplied when the caller has NOT
|
|
14
|
+
passed its own `no-data` slot.
|
|
15
|
+
-->
|
|
1
16
|
<template>
|
|
2
17
|
<v-row no-gutters>
|
|
3
18
|
<!-- Top Actions -->
|
|
@@ -5,13 +20,17 @@
|
|
|
5
20
|
<v-row no-gutters>
|
|
6
21
|
<slot name="actions">
|
|
7
22
|
<v-col cols="12">
|
|
8
|
-
<v-row no-gutters class="ga-2">
|
|
23
|
+
<v-row no-gutters class="ga-2 align-center table-card__actions">
|
|
24
|
+
<!--
|
|
25
|
+
The design's primary button: the accent fill that may carry a
|
|
26
|
+
white label (`--accent-strong`; white on `--accent` is 3.23:1),
|
|
27
|
+
40px, 10px radius, leading icon. Same click, same label.
|
|
28
|
+
-->
|
|
9
29
|
<v-btn
|
|
10
30
|
v-if="canCreate"
|
|
11
|
-
class="text-none"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
size="large"
|
|
31
|
+
class="text-none table-card__primary"
|
|
32
|
+
variant="flat"
|
|
33
|
+
prepend-icon="mdi-plus"
|
|
15
34
|
@click="emits('create')"
|
|
16
35
|
>
|
|
17
36
|
{{ createLabel }}
|
|
@@ -25,14 +44,14 @@
|
|
|
25
44
|
max-width="300"
|
|
26
45
|
append-inner-icon="mdi-magnify"
|
|
27
46
|
hide-details
|
|
47
|
+
class="table-card__search"
|
|
28
48
|
/>
|
|
49
|
+
<!-- Ghost/secondary, as the design specifies for Scan QR Code. -->
|
|
29
50
|
<v-btn
|
|
30
51
|
v-if="canScanVisitorQRCode"
|
|
31
52
|
text="Scan QR Code"
|
|
32
|
-
class="text-none ml-2"
|
|
33
|
-
|
|
34
|
-
variant="tonal"
|
|
35
|
-
size="large"
|
|
53
|
+
class="text-none ml-2 table-card__ghost"
|
|
54
|
+
variant="outlined"
|
|
36
55
|
@click="emits('scan')"
|
|
37
56
|
/>
|
|
38
57
|
</v-row>
|
|
@@ -45,19 +64,19 @@
|
|
|
45
64
|
<v-col cols="12">
|
|
46
65
|
<v-card
|
|
47
66
|
width="100%"
|
|
48
|
-
variant="
|
|
49
|
-
|
|
50
|
-
rounded="lg"
|
|
67
|
+
variant="flat"
|
|
68
|
+
class="table-card"
|
|
51
69
|
:loading="loading"
|
|
52
70
|
>
|
|
53
71
|
<!-- Toolbar -->
|
|
54
72
|
<v-toolbar
|
|
55
73
|
density="compact"
|
|
56
|
-
color="
|
|
74
|
+
color="transparent"
|
|
75
|
+
class="table-card__toolbar"
|
|
57
76
|
:extension-height="extensionHeight"
|
|
58
77
|
>
|
|
59
78
|
<template #prepend>
|
|
60
|
-
<v-btn fab icon density="comfortable" @click="emits('refresh')">
|
|
79
|
+
<v-btn fab icon density="comfortable" variant="text" @click="emits('refresh')">
|
|
61
80
|
<v-icon>mdi-refresh</v-icon>
|
|
62
81
|
</v-btn>
|
|
63
82
|
<slot name="prepend-additional" />
|
|
@@ -65,7 +84,7 @@
|
|
|
65
84
|
|
|
66
85
|
<template #append>
|
|
67
86
|
<v-row no-gutters justify="end" align="center">
|
|
68
|
-
<span class="mr-2
|
|
87
|
+
<span class="mr-2 table-card__range">
|
|
69
88
|
{{ pageRange }}
|
|
70
89
|
</span>
|
|
71
90
|
<local-pagination
|
|
@@ -96,6 +115,18 @@
|
|
|
96
115
|
<template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
|
|
97
116
|
<slot :name="slotName" v-bind="slotProps" />
|
|
98
117
|
</template>
|
|
118
|
+
|
|
119
|
+
<!--
|
|
120
|
+
The design's empty state. Same sentence the table already showed;
|
|
121
|
+
only supplied when the caller has not passed its own, so nobody's
|
|
122
|
+
existing empty state is replaced.
|
|
123
|
+
-->
|
|
124
|
+
<template v-if="!$slots['no-data']" #no-data>
|
|
125
|
+
<div class="table-card__empty">
|
|
126
|
+
<v-icon icon="mdi-magnify-close" size="32" />
|
|
127
|
+
<span>No data available</span>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
99
130
|
</v-data-table>
|
|
100
131
|
<slot name="footer" />
|
|
101
132
|
</v-card>
|
|
@@ -187,7 +218,134 @@ watch(
|
|
|
187
218
|
</script>
|
|
188
219
|
|
|
189
220
|
<style scoped>
|
|
190
|
-
|
|
191
|
-
|
|
221
|
+
/* ------------------------------------------------------------------ */
|
|
222
|
+
/* The card. */
|
|
223
|
+
/* ------------------------------------------------------------------ */
|
|
224
|
+
|
|
225
|
+
.table-card {
|
|
226
|
+
font-family: var(--font-sans);
|
|
227
|
+
background: var(--card);
|
|
228
|
+
border: 1px solid var(--border);
|
|
229
|
+
border-radius: var(--r-card);
|
|
230
|
+
box-shadow: var(--shadow);
|
|
231
|
+
overflow: hidden;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The toolbar was `color="grey-lighten-4"` - a literal Vuetify grey, which is
|
|
236
|
+
* a near-white bar sitting on a #1c1e24 card in dark mode. It is the card's
|
|
237
|
+
* own surface now, with the design's rule under it.
|
|
238
|
+
*/
|
|
239
|
+
.table-card__toolbar {
|
|
240
|
+
border-bottom: 1px solid var(--border);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.table-card__range {
|
|
244
|
+
font-size: 12px;
|
|
245
|
+
font-weight: 600;
|
|
246
|
+
color: var(--muted);
|
|
247
|
+
font-variant-numeric: tabular-nums;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ------------------------------------------------------------------ */
|
|
251
|
+
/* The header row and the rows under it. */
|
|
252
|
+
/* ------------------------------------------------------------------ */
|
|
253
|
+
|
|
254
|
+
.table-card :deep(thead th) {
|
|
255
|
+
background: var(--thead) !important;
|
|
256
|
+
color: var(--muted) !important;
|
|
257
|
+
font-size: var(--fs-table-head) !important;
|
|
258
|
+
font-weight: var(--fw-table-head) !important;
|
|
259
|
+
letter-spacing: var(--ls-table-head);
|
|
260
|
+
text-transform: uppercase;
|
|
261
|
+
padding: 10px var(--cellpad-x) !important;
|
|
262
|
+
height: auto !important;
|
|
263
|
+
border-bottom: 1px solid var(--border) !important;
|
|
264
|
+
white-space: nowrap;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.table-card :deep(tbody td) {
|
|
268
|
+
font-size: var(--fs-cell);
|
|
269
|
+
font-weight: var(--fw-cell);
|
|
270
|
+
color: var(--text);
|
|
271
|
+
padding: var(--cellpad) var(--cellpad-x) !important;
|
|
272
|
+
height: auto !important;
|
|
273
|
+
font-variant-numeric: tabular-nums;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.table-card :deep(tbody tr:hover > td) {
|
|
277
|
+
background: var(--hover);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ------------------------------------------------------------------ */
|
|
281
|
+
/* Empty state, in-card search, buttons and in-card tabs. */
|
|
282
|
+
/* ------------------------------------------------------------------ */
|
|
283
|
+
|
|
284
|
+
.table-card__empty {
|
|
285
|
+
display: flex;
|
|
286
|
+
flex-direction: column;
|
|
287
|
+
align-items: center;
|
|
288
|
+
justify-content: center;
|
|
289
|
+
gap: 8px;
|
|
290
|
+
padding: 52px 16px;
|
|
291
|
+
font-size: var(--fs-cell);
|
|
292
|
+
color: var(--muted);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.table-card__empty :deep(.v-icon) {
|
|
296
|
+
color: var(--muted);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* The in-card variant: 34px, 10px radius, as drawn. */
|
|
300
|
+
.table-card__search :deep(.v-field) {
|
|
301
|
+
border-radius: var(--r-inner);
|
|
302
|
+
font-size: var(--fs-cell);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.table-card__primary {
|
|
306
|
+
height: var(--btn-h);
|
|
307
|
+
border-radius: var(--r-inner);
|
|
308
|
+
padding: 0 16px;
|
|
309
|
+
font-size: var(--fs-btn);
|
|
310
|
+
font-weight: var(--fw-btn-strong);
|
|
311
|
+
letter-spacing: 0;
|
|
312
|
+
background: var(--accent-strong);
|
|
313
|
+
color: var(--on-accent-strong);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.table-card__ghost {
|
|
317
|
+
height: var(--btn-h);
|
|
318
|
+
border-radius: var(--r-inner);
|
|
319
|
+
padding: 0 16px;
|
|
320
|
+
font-size: var(--fs-btn);
|
|
321
|
+
font-weight: var(--fw-btn);
|
|
322
|
+
letter-spacing: 0;
|
|
323
|
+
color: var(--text2);
|
|
324
|
+
border-color: var(--border);
|
|
325
|
+
background: var(--card);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Tabs that a screen puts in this card's extension slot: 13px, the active one
|
|
330
|
+
* at 800 in `--accent-text` over a 2px accent underline. Tabs elsewhere in the
|
|
331
|
+
* product are their own screens' and are restyled with them.
|
|
332
|
+
*/
|
|
333
|
+
.table-card :deep(.v-tab) {
|
|
334
|
+
font-size: var(--fs-nav);
|
|
335
|
+
font-weight: var(--fw-cell);
|
|
336
|
+
letter-spacing: 0;
|
|
337
|
+
text-transform: none;
|
|
338
|
+
color: var(--muted);
|
|
339
|
+
min-width: 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.table-card :deep(.v-tab.v-tab--selected) {
|
|
343
|
+
font-weight: var(--fw-table-head);
|
|
344
|
+
color: var(--accent-text);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.table-card :deep(.v-tab .v-tab__slider) {
|
|
348
|
+
height: 2px;
|
|
349
|
+
background: var(--accent);
|
|
192
350
|
}
|
|
193
351
|
</style>
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|