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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.changeset/notification-preferences.md +17 -0
  2. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  3. package/CHANGELOG.md +6 -0
  4. package/assets/css/primitives.css +680 -0
  5. package/assets/css/screens.css +541 -0
  6. package/assets/css/shell.css +258 -0
  7. package/assets/css/tokens.css +220 -0
  8. package/components/AccessCardQrTagging.vue +1 -1
  9. package/components/AccessManagement.vue +53 -72
  10. package/components/AppButton.vue +52 -0
  11. package/components/AppCard.vue +20 -0
  12. package/components/AppDateField.vue +35 -0
  13. package/components/AppField.vue +46 -0
  14. package/components/AppKpiTile.vue +65 -0
  15. package/components/AppSegmented.vue +38 -0
  16. package/components/AppSelect.vue +203 -0
  17. package/components/AreaChecklistHistoryMain.vue +6 -2
  18. package/components/AreaMain.vue +9 -17
  19. package/components/AttendanceMain.vue +4 -3
  20. package/components/Avatar/Main.vue +54 -2
  21. package/components/BillingMain.vue +26 -30
  22. package/components/BuildingManagement/buildings.vue +27 -38
  23. package/components/BuildingManagement/units.vue +26 -42
  24. package/components/BulletinBoardManagement.vue +23 -10
  25. package/components/CardToolbar.vue +37 -0
  26. package/components/CleaningScheduleMain.vue +29 -46
  27. package/components/ClientMain.vue +90 -78
  28. package/components/DashboardEmptyState.vue +11 -1
  29. package/components/DashboardMain.vue +285 -207
  30. package/components/DocumentManagement.vue +129 -93
  31. package/components/EntryPassMain.vue +49 -27
  32. package/components/EquipmentItemMain.vue +4 -6
  33. package/components/EquipmentManagementMain.vue +9 -7
  34. package/components/FeedbackMain.vue +35 -49
  35. package/components/FormDialog.vue +60 -21
  36. package/components/HidIntercomManagement.vue +40 -24
  37. package/components/HidQrCodeConfiguration.vue +24 -2
  38. package/components/HidUserEnrollment.vue +68 -31
  39. package/components/InvitationMain.vue +46 -61
  40. package/components/Layout/Header.vue +289 -37
  41. package/components/Layout/NavigationDrawer.vue +299 -30
  42. package/components/ManageChecklistMain.vue +17 -17
  43. package/components/MemberMain.vue +79 -60
  44. package/components/MyAttendanceMain.vue +3 -5
  45. package/components/Nfc/NFCPatrolRouteMain.vue +64 -96
  46. package/components/NotificationSettings.vue +361 -0
  47. package/components/OnlineFormsConfiguration.vue +1 -1
  48. package/components/PageHeader.vue +43 -0
  49. package/components/PassInformation.vue +104 -45
  50. package/components/RolePermissionMain.vue +184 -91
  51. package/components/ScanVisitorQRCode.vue +35 -7
  52. package/components/ScheduleAreaMain.vue +15 -49
  53. package/components/ScheduleTaskMain.vue +23 -26
  54. package/components/ScheduleTastTicketMain.vue +18 -43
  55. package/components/ServiceProviderMain.vue +113 -168
  56. package/components/SiteSettings.vue +21 -8
  57. package/components/SpecificAttr.vue +10 -2
  58. package/components/StatusChip.vue +93 -0
  59. package/components/SwitchContext.vue +33 -1
  60. package/components/TableHygiene.vue +29 -46
  61. package/components/TableList.vue +1 -1
  62. package/components/TableListSecondary.vue +1 -1
  63. package/components/TableMain.vue +161 -77
  64. package/components/TableV3.vue +61 -66
  65. package/components/UnitMain.vue +9 -17
  66. package/components/VehicleManagement.vue +20 -9
  67. package/components/VisitorForm.vue +100 -7
  68. package/components/VisitorManagement.vue +219 -68
  69. package/components/WorkOrder/Main.vue +42 -51
  70. package/composables/useHidAmico.ts +26 -2
  71. package/composables/useNotificationPreference.ts +87 -0
  72. package/composables/useOrg.ts +1 -1
  73. package/composables/useRole.ts +22 -0
  74. package/composables/useSipWebPhone.ts +28 -3
  75. package/composables/useThemePreference.ts +63 -0
  76. package/composables/useVisitor.ts +6 -1
  77. package/nuxt.config.ts +31 -0
  78. package/package.json +3 -2
  79. package/pages/notification-settings.vue +19 -0
  80. package/plugins/vuetify.ts +32 -1
  81. package/types/org.d.ts +2 -0
  82. package/utils/nav-sections.test.ts +190 -0
  83. package/utils/nav-sections.ts +151 -0
  84. package/utils/status.test.ts +77 -0
  85. package/utils/status.ts +90 -0
  86. package/utils/theme.test.ts +325 -7
  87. package/utils/theme.ts +268 -137
@@ -33,7 +33,7 @@ type HidVisitorQrData = {
33
33
  readerId: string;
34
34
  hidUserId: number;
35
35
  qrValue: string;
36
- qrFormat: "0" | "1" | "2";
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; qrFormat: "0" | "1" | "2" },
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
+ }
@@ -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}`, {
@@ -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
- constraints: { audio: true, video: false },
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: true, video: false },
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: true, video: false },
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
- }).filter(([, value]) => value !== undefined && value !== null && value !== "")
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.1-staging.75",
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>
@@ -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
@@ -11,4 +11,6 @@ declare type TOrg = {
11
11
  status?: string;
12
12
  deletedAt?: string;
13
13
  nature?: string;
14
+ terms?: string;
15
+ policies?: string;
14
16
  };
@@ -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
+ });