@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.
- package/.changeset/notification-preferences.md +17 -0
- package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
- package/CHANGELOG.md +6 -0
- package/assets/css/primitives.css +680 -0
- package/assets/css/screens.css +541 -0
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +220 -0
- package/components/AccessCardQrTagging.vue +1 -1
- package/components/AccessManagement.vue +53 -72
- package/components/AppButton.vue +52 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +35 -0
- package/components/AppField.vue +46 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +203 -0
- package/components/AreaChecklistHistoryMain.vue +6 -2
- package/components/AreaMain.vue +9 -17
- package/components/AttendanceMain.vue +4 -3
- package/components/Avatar/Main.vue +54 -2
- package/components/BillingMain.vue +26 -30
- package/components/BuildingManagement/buildings.vue +27 -38
- package/components/BuildingManagement/units.vue +26 -42
- package/components/BulletinBoardManagement.vue +23 -10
- package/components/CardToolbar.vue +37 -0
- package/components/CleaningScheduleMain.vue +29 -46
- package/components/ClientMain.vue +90 -78
- package/components/DashboardEmptyState.vue +11 -1
- package/components/DashboardMain.vue +285 -207
- package/components/DocumentManagement.vue +129 -93
- package/components/EntryPassMain.vue +49 -27
- package/components/EquipmentItemMain.vue +4 -6
- package/components/EquipmentManagementMain.vue +9 -7
- package/components/FeedbackMain.vue +35 -49
- package/components/FormDialog.vue +60 -21
- package/components/HidIntercomManagement.vue +40 -24
- package/components/HidQrCodeConfiguration.vue +24 -2
- package/components/HidUserEnrollment.vue +68 -31
- package/components/InvitationMain.vue +46 -61
- package/components/Layout/Header.vue +289 -37
- package/components/Layout/NavigationDrawer.vue +299 -30
- package/components/ManageChecklistMain.vue +17 -17
- package/components/MemberMain.vue +79 -60
- package/components/MyAttendanceMain.vue +3 -5
- package/components/Nfc/NFCPatrolRouteMain.vue +64 -96
- package/components/NotificationSettings.vue +361 -0
- package/components/OnlineFormsConfiguration.vue +1 -1
- package/components/PageHeader.vue +43 -0
- package/components/PassInformation.vue +104 -45
- package/components/RolePermissionMain.vue +184 -91
- package/components/ScanVisitorQRCode.vue +35 -7
- package/components/ScheduleAreaMain.vue +15 -49
- package/components/ScheduleTaskMain.vue +23 -26
- package/components/ScheduleTastTicketMain.vue +18 -43
- package/components/ServiceProviderMain.vue +113 -168
- package/components/SiteSettings.vue +21 -8
- package/components/SpecificAttr.vue +10 -2
- package/components/StatusChip.vue +93 -0
- package/components/SwitchContext.vue +33 -1
- package/components/TableHygiene.vue +29 -46
- package/components/TableList.vue +1 -1
- package/components/TableListSecondary.vue +1 -1
- package/components/TableMain.vue +161 -77
- package/components/TableV3.vue +61 -66
- package/components/UnitMain.vue +9 -17
- package/components/VehicleManagement.vue +20 -9
- package/components/VisitorForm.vue +100 -7
- package/components/VisitorManagement.vue +219 -68
- package/components/WorkOrder/Main.vue +42 -51
- package/composables/useHidAmico.ts +26 -2
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useOrg.ts +1 -1
- package/composables/useRole.ts +22 -0
- package/composables/useSipWebPhone.ts +28 -3
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/nuxt.config.ts +31 -0
- package/package.json +3 -2
- package/pages/notification-settings.vue +19 -0
- package/plugins/vuetify.ts +32 -1
- package/types/org.d.ts +2 -0
- package/utils/nav-sections.test.ts +190 -0
- package/utils/nav-sections.ts +151 -0
- package/utils/status.test.ts +77 -0
- package/utils/status.ts +90 -0
- package/utils/theme.test.ts +325 -7
- package/utils/theme.ts +268 -137
|
@@ -33,7 +33,7 @@ type HidVisitorQrData = {
|
|
|
33
33
|
readerId: string;
|
|
34
34
|
hidUserId: number;
|
|
35
35
|
qrValue: string;
|
|
36
|
-
qrFormat: "0"
|
|
36
|
+
qrFormat: "0";
|
|
37
37
|
issuedAt: string;
|
|
38
38
|
expiresAt: string;
|
|
39
39
|
};
|
|
@@ -120,7 +120,7 @@ export default function useHidAmico() {
|
|
|
120
120
|
|
|
121
121
|
function issueVisitorQr(
|
|
122
122
|
readerId: string,
|
|
123
|
-
payload: { visitorId: string; validityMinutes: number
|
|
123
|
+
payload: { visitorId: string; validityMinutes: number },
|
|
124
124
|
) {
|
|
125
125
|
return useNuxtApp().$api<HidVisitorQrResponse>(`${basePath}/readers/${readerId}/visitor-qr`, {
|
|
126
126
|
method: "POST",
|
|
@@ -189,6 +189,27 @@ export default function useHidAmico() {
|
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
function getUserPinStatus(readerId: string, hidUserId: string | number) {
|
|
193
|
+
return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
|
|
194
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
|
|
195
|
+
{ method: "GET" },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function setUserPin(readerId: string, hidUserId: string | number, pin: string) {
|
|
200
|
+
return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
|
|
201
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
|
|
202
|
+
{ method: "PUT", body: { pin } },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function deleteUserPin(readerId: string, hidUserId: string | number) {
|
|
207
|
+
return useNuxtApp().$api<{ data: { pinEnrolled: boolean } }>(
|
|
208
|
+
`${basePath}/readers/${readerId}/users/${hidUserId}/pin`,
|
|
209
|
+
{ method: "DELETE" },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
function createIdentity(readerId: string, payload: HidIdentityPayload) {
|
|
193
214
|
return useNuxtApp().$api<Record<string, any>>(`${basePath}/readers/${readerId}/identities`, {
|
|
194
215
|
method: "POST",
|
|
@@ -286,6 +307,9 @@ export default function useHidAmico() {
|
|
|
286
307
|
getUserImage,
|
|
287
308
|
setUserImage,
|
|
288
309
|
deleteUserImage,
|
|
310
|
+
getUserPinStatus,
|
|
311
|
+
setUserPin,
|
|
312
|
+
deleteUserPin,
|
|
289
313
|
getIdentities,
|
|
290
314
|
createIdentity,
|
|
291
315
|
updateIdentity,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's own notification settings.
|
|
3
|
+
*
|
|
4
|
+
* The server decides everything that matters here: which categories this person
|
|
5
|
+
* is offered, which channels each one can actually reach them on, and what the
|
|
6
|
+
* words are. Nothing on this side maintains a second list — a screen that made
|
|
7
|
+
* up its own module names is how the previous version came to show three
|
|
8
|
+
* switches that governed nothing.
|
|
9
|
+
*
|
|
10
|
+
* There is no user id in either call. Whose settings these are comes from the
|
|
11
|
+
* session, so a person can only ever see and change their own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TNotificationChannelSetting = {
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** false = this category cannot reach them this way; drawn, dimmed, explained */
|
|
18
|
+
supported: boolean;
|
|
19
|
+
/** the plain-English reason, present only when unsupported */
|
|
20
|
+
note?: string;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TNotificationCategorySetting = {
|
|
25
|
+
key: string;
|
|
26
|
+
label: string;
|
|
27
|
+
description: string;
|
|
28
|
+
safety: boolean;
|
|
29
|
+
channels: TNotificationChannelSetting[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TNotificationPreferences = {
|
|
33
|
+
channels: Array<{ key: string; label: string }>;
|
|
34
|
+
categories: TNotificationCategorySetting[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const EMPTY: TNotificationPreferences = { channels: [], categories: [] };
|
|
38
|
+
|
|
39
|
+
export default function useNotificationPreference() {
|
|
40
|
+
function getPreferences() {
|
|
41
|
+
return useNuxtApp().$api<{ data: TNotificationPreferences }>(
|
|
42
|
+
"/api/notification-preferences",
|
|
43
|
+
{ method: "GET" },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Save the whole screen at once.
|
|
49
|
+
*
|
|
50
|
+
* What is sent is the list of switches that are OFF, which is also how the
|
|
51
|
+
* server stores it: somebody who has changed nothing has no stored row at
|
|
52
|
+
* all, and behaves exactly as they did before this screen existed.
|
|
53
|
+
*/
|
|
54
|
+
function savePreferences(preferences: TNotificationPreferences) {
|
|
55
|
+
const off = preferences.categories.flatMap((category) =>
|
|
56
|
+
category.channels
|
|
57
|
+
.filter((channel) => channel.supported && !channel.enabled)
|
|
58
|
+
.map((channel) => ({ category: category.key, channel: channel.key })),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return useNuxtApp().$api<{ data: TNotificationPreferences }>(
|
|
62
|
+
"/api/notification-preferences",
|
|
63
|
+
{ method: "PUT", body: { off } },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { getPreferences, savePreferences, EMPTY_PREFERENCES: EMPTY };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The sentence shown when somebody switches a safety or security alert off.
|
|
72
|
+
*
|
|
73
|
+
* It says what will stop arriving and how to undo it, and it does not argue —
|
|
74
|
+
* the owner's decision is that these can be switched off, so the job here is to
|
|
75
|
+
* make sure nobody does it by accident, not to talk them out of it.
|
|
76
|
+
*/
|
|
77
|
+
export function safetyOffConfirmation(categoryLabel: string, channelLabel: string) {
|
|
78
|
+
return {
|
|
79
|
+
title: `Turn off ${categoryLabel}?`,
|
|
80
|
+
body:
|
|
81
|
+
`You will stop getting ${categoryLabel.toLowerCase()} alerts under "${channelLabel}". ` +
|
|
82
|
+
`These are safety and security alerts, so they are on for everyone by default. ` +
|
|
83
|
+
`You can turn them back on here at any time.`,
|
|
84
|
+
confirm: "Turn it off",
|
|
85
|
+
cancel: "Keep it on",
|
|
86
|
+
};
|
|
87
|
+
}
|
package/composables/useOrg.ts
CHANGED
|
@@ -170,7 +170,7 @@ export default function useOrg() {
|
|
|
170
170
|
|
|
171
171
|
function updateOrg(
|
|
172
172
|
id: string,
|
|
173
|
-
value: Partial<Pick<TOrg, "name" | "type" | "email" | "nature" | "contact">>
|
|
173
|
+
value: Partial<Pick<TOrg, "name" | "type" | "email" | "nature" | "contact" | "terms" | "policies">>
|
|
174
174
|
) {
|
|
175
175
|
return useNuxtApp()
|
|
176
176
|
.$api<Record<string, any>>(`/api/organizations/${id}`, {
|
package/composables/useRole.ts
CHANGED
|
@@ -68,6 +68,26 @@ export default function useRole() {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function getDeletionPreview(_id: string) {
|
|
72
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
73
|
+
`/api/roles/${_id}/deletion-preview`,
|
|
74
|
+
{ method: "GET" }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deleteWithReassignments(
|
|
79
|
+
_id: string,
|
|
80
|
+
reassignments: Array<{ memberId: string; roleId: string }>
|
|
81
|
+
) {
|
|
82
|
+
return useNuxtApp().$api<Record<string, any>>(
|
|
83
|
+
`/api/roles/${_id}/delete-with-reassignments`,
|
|
84
|
+
{
|
|
85
|
+
method: "PUT",
|
|
86
|
+
body: { reassignments },
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
const role = useState<TRole>("userRole", () => {
|
|
72
92
|
return {
|
|
73
93
|
_id: "",
|
|
@@ -103,6 +123,8 @@ export default function useRole() {
|
|
|
103
123
|
updateRoleById,
|
|
104
124
|
updateRoleFieldById,
|
|
105
125
|
deleteRole,
|
|
126
|
+
getDeletionPreview,
|
|
127
|
+
deleteWithReassignments,
|
|
106
128
|
updatePermissionById,
|
|
107
129
|
getRoleByUserId,
|
|
108
130
|
};
|
|
@@ -31,6 +31,12 @@ export default function useSipWebPhone() {
|
|
|
31
31
|
|
|
32
32
|
const isRegistered = computed(() => connectionState.value === "registered");
|
|
33
33
|
const hasCall = computed(() => callState.value !== "idle");
|
|
34
|
+
const audioConstraints: MediaTrackConstraints = {
|
|
35
|
+
echoCancellation: true,
|
|
36
|
+
noiseSuppression: true,
|
|
37
|
+
autoGainControl: true,
|
|
38
|
+
channelCount: 1,
|
|
39
|
+
};
|
|
34
40
|
|
|
35
41
|
async function connect(config: SipWebPhoneConfig, media: SipWebPhoneMedia) {
|
|
36
42
|
validateConfig(config);
|
|
@@ -73,6 +79,7 @@ export default function useSipWebPhone() {
|
|
|
73
79
|
},
|
|
74
80
|
onCallAnswered: () => {
|
|
75
81
|
callState.value = "active";
|
|
82
|
+
ensureMicrophoneSending();
|
|
76
83
|
syncVideoState();
|
|
77
84
|
},
|
|
78
85
|
onCallHangup: () => {
|
|
@@ -104,7 +111,9 @@ export default function useSipWebPhone() {
|
|
|
104
111
|
aor: normalizeSipUri(config.aor),
|
|
105
112
|
delegate,
|
|
106
113
|
media: {
|
|
107
|
-
|
|
114
|
+
// The browser sends microphone audio and receives HID audio/video.
|
|
115
|
+
// Do not request a local camera merely because the HID video leg is enabled.
|
|
116
|
+
constraints: { audio: audioConstraints, video: false },
|
|
108
117
|
remote: {
|
|
109
118
|
audio: media.remoteAudio || undefined,
|
|
110
119
|
video: media.remoteVideo || undefined,
|
|
@@ -167,7 +176,7 @@ export default function useSipWebPhone() {
|
|
|
167
176
|
callState.value = "calling";
|
|
168
177
|
try {
|
|
169
178
|
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
170
|
-
constraints: { audio:
|
|
179
|
+
constraints: { audio: audioConstraints, video: false },
|
|
171
180
|
offerOptions: {
|
|
172
181
|
offerToReceiveAudio: true,
|
|
173
182
|
offerToReceiveVideo: Boolean(activeConfig.value?.video),
|
|
@@ -184,7 +193,11 @@ export default function useSipWebPhone() {
|
|
|
184
193
|
async function answer() {
|
|
185
194
|
const user = requireRegisteredUser();
|
|
186
195
|
const sessionDescriptionHandlerOptions: SessionDescriptionHandlerOptions = {
|
|
187
|
-
constraints: { audio:
|
|
196
|
+
constraints: { audio: audioConstraints, video: false },
|
|
197
|
+
offerOptions: {
|
|
198
|
+
offerToReceiveAudio: true,
|
|
199
|
+
offerToReceiveVideo: Boolean(activeConfig.value?.video),
|
|
200
|
+
},
|
|
188
201
|
};
|
|
189
202
|
await user.answer({ sessionDescriptionHandlerOptions });
|
|
190
203
|
}
|
|
@@ -214,6 +227,18 @@ export default function useSipWebPhone() {
|
|
|
214
227
|
muted.value = !enable;
|
|
215
228
|
}
|
|
216
229
|
|
|
230
|
+
function ensureMicrophoneSending() {
|
|
231
|
+
const tracks = simpleUser.value?.localMediaStream?.getAudioTracks() || [];
|
|
232
|
+
if (!tracks.length) {
|
|
233
|
+
errorMessage.value = "The call connected without a microphone track. Reconnect and allow microphone access.";
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
tracks.forEach((track) => {
|
|
237
|
+
track.enabled = true;
|
|
238
|
+
});
|
|
239
|
+
muted.value = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
217
242
|
function syncVideoState() {
|
|
218
243
|
const user = simpleUser.value;
|
|
219
244
|
const remoteStream = user?.remoteMediaStream;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE USER'S LIGHT/DARK CHOICE, REMEMBERED.
|
|
3
|
+
*
|
|
4
|
+
* Until now `Layout/Header.vue` flipped `theme.global.name` and wrote nothing,
|
|
5
|
+
* so the choice was gone on the next reload.
|
|
6
|
+
*
|
|
7
|
+
* A COOKIE, NOT localStorage. The handoff says "persists in localStorage", and
|
|
8
|
+
* for a single-page prototype that is right - but this product is ELEVEN
|
|
9
|
+
* applications on eleven hostnames. localStorage is per-origin, so a user who
|
|
10
|
+
* picked dark mode in Security would land on a white screen in Property
|
|
11
|
+
* Management, and again in Account, and would have to set it eleven times.
|
|
12
|
+
*
|
|
13
|
+
* A cookie written with the layer's own `cookieConfig` carries the parent
|
|
14
|
+
* domain, which is exactly how `sid`, `user` and `landing-page` already travel
|
|
15
|
+
* between these applications. So this is the estate's existing preference
|
|
16
|
+
* mechanism rather than a new one, and it is also the only one that makes the
|
|
17
|
+
* setting mean what a user expects it to mean.
|
|
18
|
+
*
|
|
19
|
+
* NO FLASH OF THE WRONG THEME. Nothing here corrects the theme after mount -
|
|
20
|
+
* `plugins/vuetify.ts` reads the same cookie BEFORE `createVuetify` and boots
|
|
21
|
+
* on it, so the first paint is already right. The apps are `ssr: false`, so
|
|
22
|
+
* there is no server render to disagree with.
|
|
23
|
+
*/
|
|
24
|
+
import { useTheme } from "vuetify";
|
|
25
|
+
import type { CookieOptions } from "#app";
|
|
26
|
+
|
|
27
|
+
export type TThemeName = "light" | "dark";
|
|
28
|
+
|
|
29
|
+
/** The one place the cookie is named. `plugins/vuetify.ts` reads the same one. */
|
|
30
|
+
export const THEME_COOKIE = "theme";
|
|
31
|
+
|
|
32
|
+
export default function useThemePreference() {
|
|
33
|
+
// `runtimeConfig.public.cookieConfig` is typed `unknown`, so it is narrowed
|
|
34
|
+
// here rather than at every call site. Same object `sid`, `user` and
|
|
35
|
+
// `landing-page` are written with - it carries the shared parent domain.
|
|
36
|
+
const cookieConfig = useRuntimeConfig().public.cookieConfig as CookieOptions<
|
|
37
|
+
TThemeName | null
|
|
38
|
+
> & { readonly?: false };
|
|
39
|
+
|
|
40
|
+
const vuetifyTheme = useTheme();
|
|
41
|
+
|
|
42
|
+
const stored = useCookie<TThemeName | null>(THEME_COOKIE, cookieConfig);
|
|
43
|
+
|
|
44
|
+
const current = computed<TThemeName>(() =>
|
|
45
|
+
vuetifyTheme.global.current.value.dark ? "dark" : "light"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sets the theme AND remembers it. The write is the whole point - a caller
|
|
50
|
+
* that only wants a temporary theme for one subtree should use
|
|
51
|
+
* `<v-theme-provider>` instead, which is what `plain-dark` does.
|
|
52
|
+
*/
|
|
53
|
+
function setTheme(name: TThemeName) {
|
|
54
|
+
vuetifyTheme.global.name.value = name;
|
|
55
|
+
stored.value = name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toggleTheme() {
|
|
59
|
+
setTheme(current.value === "dark" ? "light" : "dark");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { current, setTheme, toggleTheme, stored };
|
|
63
|
+
}
|
|
@@ -92,6 +92,7 @@ export default function () {
|
|
|
92
92
|
checkedOut?: boolean;
|
|
93
93
|
plateNumber?: string;
|
|
94
94
|
tab?: string;
|
|
95
|
+
passOrKey?: string;
|
|
95
96
|
};
|
|
96
97
|
|
|
97
98
|
type SearchUncheckedOutUnregisteredVisitorParams = {
|
|
@@ -130,6 +131,7 @@ export default function () {
|
|
|
130
131
|
plateNumber = "",
|
|
131
132
|
checkedOut,
|
|
132
133
|
tab,
|
|
134
|
+
passOrKey,
|
|
133
135
|
}: GetVisitorsParams = {}) {
|
|
134
136
|
const query = Object.fromEntries(
|
|
135
137
|
Object.entries({
|
|
@@ -146,7 +148,10 @@ export default function () {
|
|
|
146
148
|
plateNumber,
|
|
147
149
|
order,
|
|
148
150
|
tab,
|
|
149
|
-
|
|
151
|
+
passOrKey,
|
|
152
|
+
}).filter(
|
|
153
|
+
([, value]) => value !== undefined && value !== null && value !== ""
|
|
154
|
+
)
|
|
150
155
|
);
|
|
151
156
|
|
|
152
157
|
return await useNuxtApp().$api<Record<string, any>>(
|
package/nuxt.config.ts
CHANGED
|
@@ -7,6 +7,37 @@ export default defineNuxtConfig({
|
|
|
7
7
|
transpile: ["vuetify"],
|
|
8
8
|
},
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* THE REDESIGN'S TYPEFACE - LOADED, DELIBERATELY NOT APPLIED YET.
|
|
12
|
+
*
|
|
13
|
+
* `--font-sans` in `assets/css/tokens.css` names Manrope, and a token that
|
|
14
|
+
* names a font nobody loaded is a trap: the next phase writes
|
|
15
|
+
* `font-family: var(--font-sans)`, silently falls back to Arial, and looks
|
|
16
|
+
* fine to whoever wrote it. So the stylesheet is requested here.
|
|
17
|
+
*
|
|
18
|
+
* Nothing sets `font-family` on the page in Phase 1. Switching eleven
|
|
19
|
+
* applications to a new typeface before one screen has been rebuilt would
|
|
20
|
+
* leave the whole product looking half-migrated; each phase adopts the token
|
|
21
|
+
* as it rebuilds its own surfaces. Browsers do not download a webfont no
|
|
22
|
+
* element uses, so until then this costs one small CSS request.
|
|
23
|
+
*/
|
|
24
|
+
app: {
|
|
25
|
+
head: {
|
|
26
|
+
link: [
|
|
27
|
+
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
|
28
|
+
{
|
|
29
|
+
rel: "preconnect",
|
|
30
|
+
href: "https://fonts.gstatic.com",
|
|
31
|
+
crossorigin: "",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
rel: "stylesheet",
|
|
35
|
+
href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
10
41
|
runtimeConfig: {
|
|
11
42
|
public: {
|
|
12
43
|
cookieConfig: {
|
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.
|
|
5
|
+
"version": "3.2.2-staging.100",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"publishConfig": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"build": "nuxt build .playground",
|
|
15
15
|
"generate": "nuxt generate .playground",
|
|
16
16
|
"preview": "nuxt preview .playground",
|
|
17
|
-
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\"",
|
|
17
|
+
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\" && yarn test:units",
|
|
18
|
+
"test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
|
|
18
19
|
"release": "yarn run build && changeset publish"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NotificationSettings />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* Notification settings, reachable from Profile.
|
|
8
|
+
*
|
|
9
|
+
* The page lives in the shared layer so every web app has the route and there
|
|
10
|
+
* is only one screen to keep correct; the link to it sits in Profile, in the
|
|
11
|
+
* account app, which is where "Manage Account" already sends everybody.
|
|
12
|
+
*/
|
|
13
|
+
definePageMeta({
|
|
14
|
+
middleware: ["01-auth"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { authenticate } = useLocalAuth();
|
|
18
|
+
authenticate();
|
|
19
|
+
</script>
|
package/plugins/vuetify.ts
CHANGED
|
@@ -43,8 +43,39 @@ import {
|
|
|
43
43
|
import "vuetify-pro-tiptap/style.css";
|
|
44
44
|
|
|
45
45
|
import { LIGHT_THEME, DARK_THEME } from "../utils/theme";
|
|
46
|
+
import { THEME_COOKIE } from "../composables/useThemePreference";
|
|
47
|
+
import type { CookieOptions } from "#app";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The design tokens. Loaded HERE rather than in `nuxt.config.ts` so they land
|
|
51
|
+
* after `vuetify/styles` in the bundle - `tokens.css` aliases `--v-theme-*`,
|
|
52
|
+
* which Vuetify has to have emitted first.
|
|
53
|
+
*/
|
|
54
|
+
import "../assets/css/tokens.css";
|
|
55
|
+
import "../assets/css/shell.css";
|
|
56
|
+
import "../assets/css/screens.css";
|
|
57
|
+
import "../assets/css/primitives.css";
|
|
46
58
|
|
|
47
59
|
export default defineNuxtPlugin((app) => {
|
|
60
|
+
/**
|
|
61
|
+
* BOOT ON THE REMEMBERED THEME, DON'T CORRECT IT AFTERWARDS.
|
|
62
|
+
*
|
|
63
|
+
* Reading the cookie here, before `createVuetify`, is what makes the choice
|
|
64
|
+
* survive a reload with no flash: Vuetify's first paint is already the right
|
|
65
|
+
* theme, so there is no moment where the wrong one is on screen. Setting it
|
|
66
|
+
* from a component's `onMounted` would repaint the whole application one
|
|
67
|
+
* frame in, which is the flash this is written to avoid.
|
|
68
|
+
*
|
|
69
|
+
* `useThemePreference()` writes this cookie; nothing else does.
|
|
70
|
+
*/
|
|
71
|
+
const stored = useCookie<string | null>(
|
|
72
|
+
THEME_COOKIE,
|
|
73
|
+
// typed `unknown` in runtimeConfig; same object `sid` and `user` use
|
|
74
|
+
useRuntimeConfig().public.cookieConfig as CookieOptions<string | null> & {
|
|
75
|
+
readonly?: false;
|
|
76
|
+
}
|
|
77
|
+
).value;
|
|
78
|
+
|
|
48
79
|
const vuetify = createVuetify({
|
|
49
80
|
defaults: {
|
|
50
81
|
VTextField: {
|
|
@@ -97,7 +128,7 @@ export default defineNuxtPlugin((app) => {
|
|
|
97
128
|
* `utils/theme.ts`, which `utils/theme.test.ts` measures on every run.
|
|
98
129
|
*/
|
|
99
130
|
theme: {
|
|
100
|
-
defaultTheme: "light",
|
|
131
|
+
defaultTheme: stored === "dark" ? "dark" : "light",
|
|
101
132
|
themes: {
|
|
102
133
|
light: LIGHT_THEME,
|
|
103
134
|
dark: DARK_THEME,
|
package/types/org.d.ts
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
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. Grouping may reorder the rail; it may never change
|
|
32
|
+
* the menu. Same objects, same count - nothing added, hidden or duplicated.
|
|
33
|
+
*/
|
|
34
|
+
test("every menu item comes back exactly once, and nothing else does", () => {
|
|
35
|
+
const out = withSections(SECURITY);
|
|
36
|
+
|
|
37
|
+
assert.equal(out.length, SECURITY.length);
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
new Set(out.map((e) => e.item)),
|
|
40
|
+
new Set(SECURITY)
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("Security is labelled the way the design draws it", () => {
|
|
45
|
+
const labelled = withSections(SECURITY)
|
|
46
|
+
.filter((e) => e.section)
|
|
47
|
+
.map((e) => `${e.section}: ${e.item.title}`);
|
|
48
|
+
|
|
49
|
+
assert.deepEqual(labelled, [
|
|
50
|
+
"OVERVIEW: Dashboard",
|
|
51
|
+
"SERVICE DESK: Feedbacks",
|
|
52
|
+
"PROPERTY: Vehicle Mgmt",
|
|
53
|
+
"SECURITY OPERATIONS: HID Face Reader",
|
|
54
|
+
"COMMUNITY: Bulletin Board",
|
|
55
|
+
"WORKFORCE: Attendance",
|
|
56
|
+
"ADMINISTRATION: Invitations",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A permission-gated menu is a SUBSET, and every application's is different.
|
|
62
|
+
* Dropping the items a user cannot see must not leave a heading behind or move
|
|
63
|
+
* one onto the wrong item.
|
|
64
|
+
*/
|
|
65
|
+
test("a user who can only see three modules gets three correct headings", () => {
|
|
66
|
+
const out = withSections(
|
|
67
|
+
[{ title: "Dashboard" }, { title: "Work Orders" }, { title: "Settings" }]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
out.map((e) => e.section),
|
|
72
|
+
["OVERVIEW", "SERVICE DESK", "ADMINISTRATION"]
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The eleven applications add menu items without touching this layer. An
|
|
78
|
+
* unknown title must appear where its author put it, under the heading it
|
|
79
|
+
* follows - never suppressed, and never given a heading of its own.
|
|
80
|
+
*/
|
|
81
|
+
test("an unmapped module inherits the section above it", () => {
|
|
82
|
+
const out = withSections([
|
|
83
|
+
{ title: "Dashboard" },
|
|
84
|
+
{ title: "Something New" },
|
|
85
|
+
{ title: "Members" },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
out.map((e) => e.section),
|
|
90
|
+
["OVERVIEW", "", "ADMINISTRATION"]
|
|
91
|
+
);
|
|
92
|
+
assert.equal(out[1].item.title, "Something New");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("an unmapped FIRST item prints no heading rather than a blank one", () => {
|
|
96
|
+
const out = withSections([{ title: "Something New" }, { title: "Members" }]);
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
out.map((e) => e.section),
|
|
100
|
+
["", "ADMINISTRATION"]
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The applications do not agree on order: Landscape lists Feedbacks after its
|
|
106
|
+
* equipment modules. Those two Feedbacks/Work Orders items are collected under
|
|
107
|
+
* ONE SERVICE DESK heading instead of printing the heading twice.
|
|
108
|
+
*/
|
|
109
|
+
test("a section that comes back later is grouped, not labelled twice", () => {
|
|
110
|
+
const out = withSections([
|
|
111
|
+
{ title: "Feedbacks" },
|
|
112
|
+
{ title: "Equipment Items" },
|
|
113
|
+
{ title: "Work Orders" },
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
out.map((e) => `${e.section}|${e.item.title}`),
|
|
118
|
+
[
|
|
119
|
+
"SERVICE DESK|Feedbacks",
|
|
120
|
+
"|Work Orders",
|
|
121
|
+
"EQUIPMENT|Equipment Items",
|
|
122
|
+
]
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Property Management is the screen that showed the defect: its own menu order
|
|
128
|
+
* printed SERVICE DESK, PROPERTY and SECURITY OPERATIONS twice each.
|
|
129
|
+
*/
|
|
130
|
+
test("Property Management prints each heading exactly once", () => {
|
|
131
|
+
const PM = [
|
|
132
|
+
"Dashboard",
|
|
133
|
+
"Service Providers",
|
|
134
|
+
"Feedbacks",
|
|
135
|
+
"Work Orders",
|
|
136
|
+
"Facility Mgmt",
|
|
137
|
+
"Pass & Key Mgmt",
|
|
138
|
+
"HID Face Reader",
|
|
139
|
+
"Online Forms",
|
|
140
|
+
"NFC Patrol",
|
|
141
|
+
"Bulletin Board",
|
|
142
|
+
"Bulletin Videos",
|
|
143
|
+
"Visitor Mgmt",
|
|
144
|
+
"Document Mgmt",
|
|
145
|
+
"Access Mgmt",
|
|
146
|
+
].map((title) => ({ title }));
|
|
147
|
+
|
|
148
|
+
const out = withSections(PM);
|
|
149
|
+
const headings = out.map((e) => e.section).filter(Boolean);
|
|
150
|
+
|
|
151
|
+
assert.deepEqual(headings, [
|
|
152
|
+
"OVERVIEW",
|
|
153
|
+
"SERVICE DESK",
|
|
154
|
+
"PROPERTY",
|
|
155
|
+
"SECURITY OPERATIONS",
|
|
156
|
+
"COMMUNITY",
|
|
157
|
+
]);
|
|
158
|
+
assert.equal(new Set(headings).size, headings.length);
|
|
159
|
+
assert.deepEqual(
|
|
160
|
+
out.map((e) => e.item.title),
|
|
161
|
+
[
|
|
162
|
+
"Dashboard",
|
|
163
|
+
"Service Providers",
|
|
164
|
+
"Feedbacks",
|
|
165
|
+
"Work Orders",
|
|
166
|
+
"Online Forms",
|
|
167
|
+
"Facility Mgmt",
|
|
168
|
+
"Pass & Key Mgmt",
|
|
169
|
+
"Visitor Mgmt",
|
|
170
|
+
"Document Mgmt",
|
|
171
|
+
"Access Mgmt",
|
|
172
|
+
"HID Face Reader",
|
|
173
|
+
"NFC Patrol",
|
|
174
|
+
"Bulletin Board",
|
|
175
|
+
"Bulletin Videos",
|
|
176
|
+
]
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a missing or empty title does not throw", () => {
|
|
181
|
+
const out = withSections([{}, { title: "" }] as Array<{ title?: string }>);
|
|
182
|
+
assert.equal(out.length, 2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** Every section name is the uppercase form the type scale is drawn for. */
|
|
186
|
+
test("section names are uppercase", () => {
|
|
187
|
+
for (const section of Object.values(NAV_SECTIONS)) {
|
|
188
|
+
assert.equal(section, section.toUpperCase(), section);
|
|
189
|
+
}
|
|
190
|
+
});
|