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

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.
@@ -8,8 +8,8 @@
8
8
  :height="40"
9
9
  text="Scan QR Code"
10
10
  class="text-capitalize"
11
- disabled
12
11
  prepend-icon="mdi-qrcode"
12
+ @click="handleScanQRCode"
13
13
  />
14
14
  <v-autocomplete
15
15
  v-model="selectedPass"
@@ -88,6 +88,11 @@
88
88
  </v-card-text>
89
89
  </v-card>
90
90
  </v-row>
91
+ <visitor-pass-key-q-r-scanner
92
+ :dialog="dialog.scanQRCode"
93
+ v-model:scannedValue="scannedValue"
94
+ @close-dialog="dialog.scanQRCode = false"
95
+ />
91
96
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
92
97
  </template>
93
98
 
@@ -95,46 +100,36 @@
95
100
  import type { PropType } from "vue";
96
101
  import type { ValidationRule } from "vuetify/lib/types.mjs";
97
102
  import usePassKey from "../composables/usePassKey";
98
- import useKey from "../composables/useKey";
99
- import { string } from "zod/v4";
103
+ import { errorConverter } from "../utils/data";
104
+ import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
100
105
 
101
- const props = defineProps({
102
- passRules: {
103
- type: Array as PropType<ValidationRule[]>,
104
- default: [],
105
- },
106
- countRules: {
107
- type: Array as PropType<ValidationRule[]>,
108
- default: [],
109
- },
110
- site: {
111
- type: String,
112
- required: true,
113
- },
114
- type: {
115
- type: String as PropType<TVisitorType>,
116
- required: true,
117
- },
118
- contractorType: {
119
- type: String,
120
- default: "",
121
- },
122
- passKeys: {
123
- type: Array,
124
- default: [],
125
- },
126
- hideKeys: {
127
- type: Boolean,
128
- default: false,
129
- },
130
- clearable: {
131
- type: Boolean,
132
- default: false,
133
- },
106
+ type PassInformationProps = {
107
+ passRules?: ValidationRule[];
108
+ countRules?: ValidationRule[];
109
+ site: string;
110
+ type: TVisitorType;
111
+ contractorType?: string;
112
+ passKeys?: unknown[];
113
+ hideKeys?: boolean;
114
+ clearable?: boolean;
115
+ pass?: TPassKeyPayload[];
116
+ keys?: TPassKeyPayload[];
117
+ };
118
+
119
+ const props = withDefaults(defineProps<PassInformationProps>(), {
120
+ passRules: [],
121
+ countRules: [],
122
+ contractorType: "",
123
+ passKeys: [],
124
+ hideKeys: false,
125
+ clearable: false,
126
+ pass: [],
127
+ keys: [],
134
128
  });
135
129
 
136
- const pass = defineModel<TPassKeyPayload[]>("pass", { default: [] });
137
- const keys = defineModel<TPassKeyPayload[]>("keys", { default: [] });
130
+ const emit = defineEmits(["update:pass", "update:keys"]);
131
+ const pass = ref<TPassKeyPayload[]>(props.pass);
132
+ const keys = ref<TPassKeyPayload[]>(props.keys);
138
133
  const selectedPass = ref<string>("");
139
134
  const selectedKeys = ref<string[]>([]);
140
135
  const passInput = ref("");
@@ -144,6 +139,11 @@ const keyItems = ref<any[]>([]);
144
139
  const selectedType = ref<"qr-pass" | "nfc-card">();
145
140
  const count = ref(1);
146
141
 
142
+ const dialog = reactive({
143
+ scanQRCode: false,
144
+ });
145
+ const scannedValue = ref("");
146
+
147
147
  const { getPassKeysByPageSearch } = usePassKey();
148
148
 
149
149
  const typeItems = [
@@ -179,6 +179,23 @@ function showMessage(msg: string, color: string) {
179
179
  messageSnackbar.value = true;
180
180
  }
181
181
 
182
+ function getItemLabel(item: any) {
183
+ return (item?.prefixAndName ?? item?.raw?.prefixAndName ?? "")
184
+ .toString()
185
+ .trim();
186
+ }
187
+
188
+ function handleScanQRCode() {
189
+ scannedValue.value = "";
190
+ dialog.scanQRCode = true;
191
+ if (passItems.value.length === 0) {
192
+ fetchPasses();
193
+ }
194
+ if (!props.hideKeys && keyItems.value.length === 0) {
195
+ fetchKeys();
196
+ }
197
+ }
198
+
182
199
  const isPassListLoading = ref(false);
183
200
 
184
201
  async function fetchPasses() {
@@ -207,7 +224,7 @@ const isKeyListLoading = ref(false);
207
224
  async function fetchKeys() {
208
225
  isKeyListLoading.value = true;
209
226
  try {
210
- const keys: any = await getPassKeysByPageSearch({
227
+ const result: any = await getPassKeysByPageSearch({
211
228
  page: 1,
212
229
  sites: [props.site],
213
230
  limit: 10000,
@@ -223,7 +240,7 @@ async function fetchKeys() {
223
240
  return false;
224
241
  });
225
242
 
226
- const filteredArray = keys.items.filter((item: any) => {
243
+ const filteredArray = result.items.filter((item: any) => {
227
244
  if (Array.isArray(props?.passKeys) && props.passKeys.length) {
228
245
  return !props.passKeys.includes(item._id);
229
246
  }
@@ -231,18 +248,60 @@ async function fetchKeys() {
231
248
  });
232
249
  keyItems.value = [...previouslySelectedKeys, ...filteredArray];
233
250
  } catch (error) {
234
- showMessage(errorConverter(error as string), "error");
251
+ showMessage(errorConverter(error), "error");
235
252
  } finally {
236
253
  isKeyListLoading.value = false;
237
254
  }
238
255
  }
239
256
 
240
257
  watch(selectedPass, (newVal) => {
241
- pass.value = [{ keyId: newVal }];
258
+ const updated = newVal ? [{ keyId: newVal }] : [];
259
+ pass.value = updated;
260
+ emit("update:pass", updated);
242
261
  });
243
262
 
244
263
  watch(selectedKeys, (newVal) => {
245
- keys.value = newVal.map((key) => ({ keyId: key }));
264
+ const updated = newVal.map((key) => ({ keyId: key }));
265
+ keys.value = updated;
266
+ emit("update:keys", updated);
267
+ });
268
+
269
+ watch(scannedValue, async (newValue) => {
270
+ if (!newValue) return;
271
+
272
+ const scanned = String(newValue).trim();
273
+ if (!scanned) return;
274
+
275
+ if (passItems.value.length === 0) {
276
+ await fetchPasses();
277
+ }
278
+ if (!props.hideKeys && keyItems.value.length === 0) {
279
+ await fetchKeys();
280
+ }
281
+
282
+ const matchedPass = passItems.value.find((item) => {
283
+ return getItemLabel(item).toLowerCase() === scanned.toLowerCase();
284
+ });
285
+
286
+ if (matchedPass && matchedPass._id) {
287
+ selectedPass.value = matchedPass._id;
288
+ return;
289
+ }
290
+
291
+ if (!props.hideKeys) {
292
+ const matchedKey = keyItems.value.find((item) => {
293
+ return getItemLabel(item).toLowerCase() === scanned.toLowerCase();
294
+ });
295
+
296
+ if (matchedKey && matchedKey._id) {
297
+ if (!selectedKeys.value.includes(matchedKey._id)) {
298
+ selectedKeys.value = [...selectedKeys.value, matchedKey._id];
299
+ }
300
+ return;
301
+ }
302
+ }
303
+
304
+ showMessage(`Scanned pass or key "${scanned}" not found`, "error");
246
305
  });
247
306
 
248
307
  //prevent negative value;
@@ -254,10 +313,10 @@ watch(count, (newCount) => {
254
313
 
255
314
  onMounted(() => {
256
315
  if (pass.value.length > 0) {
257
- selectedPass.value = pass.value[0].keyId;
316
+ selectedPass.value = pass.value[0]?.keyId ?? "";
258
317
  }
259
318
  if (keys.value.length > 0) {
260
- selectedKeys.value = keys.value.map((k) => k.keyId);
319
+ selectedKeys.value = keys.value.map((k) => k.keyId ?? "");
261
320
  }
262
321
  });
263
322
  </script>
@@ -60,7 +60,9 @@ const props = defineProps({
60
60
 
61
61
  const { standardFormatDate } = useUtils();
62
62
 
63
- const { validateVisitorQrCode } = useVisitor();
63
+ const { validateVisitorQrCode, getVisitors } = useVisitor();
64
+
65
+ const { getPassKeysByPageSearch } = usePassKey();
64
66
 
65
67
  const emits = defineEmits([
66
68
  "closeDialog",
@@ -97,8 +99,6 @@ const handleScanVisitorQRCode = async () => {
97
99
  showNotFoundMessage.value = false;
98
100
  // Automatically navigate to the scanned URL if it's a valid link
99
101
  if (isValidUrl(qrCodeMessage)) {
100
- console.log("QR Code value", qrCodeMessage);
101
- console.log("Last _id", qrCodeMessage.split("/").pop());
102
102
  // get the last _id in the url
103
103
  const id = qrCodeMessage.split("/").pop();
104
104
  // check if it's a valid mongodb _id
@@ -108,7 +108,6 @@ const handleScanVisitorQRCode = async () => {
108
108
  props.site as string,
109
109
  id as string
110
110
  );
111
- console.log("validateVisitorQrCode", response);
112
111
  if (response.errorMessage) {
113
112
  showNotFoundMessage.value = true;
114
113
  errorMessage.value =
@@ -122,9 +121,38 @@ const handleScanVisitorQRCode = async () => {
122
121
  emits("openVisitorDataFromScannedQrCodeDialog", response);
123
122
  }
124
123
  } else {
125
- showNotFoundMessage.value = true;
126
- errorMessage.value = "Invalid mongodb _id.";
127
- showMessage(errorMessage.value, "error");
124
+ const result: any = await getPassKeysByPageSearch({
125
+ sites: [props.site as string],
126
+ search: qrCodeMessage,
127
+ });
128
+
129
+ if (result.items?.length < 1) {
130
+ showMessage(`Pass or Key ${qrCodeMessage} not found.`, "error");
131
+ return;
132
+ }
133
+
134
+ const data: any = await getVisitors({
135
+ site: props.site as string,
136
+ passOrKey: {
137
+ keyId: result.items[0]._id,
138
+ type: result.items[0].passType == "pass-key" ? "key" : "pass",
139
+ },
140
+ checkedOut: false,
141
+ });
142
+
143
+ if (data.items?.length < 1) {
144
+ showMessage(
145
+ `There are no unchecked-out visitor transactions for the scanned ${
146
+ result.items[0].passType == "pass-key" ? "Key" : "Pass"
147
+ } ${qrCodeMessage}.`,
148
+ "error"
149
+ );
150
+
151
+ return;
152
+ }
153
+
154
+ closeDialog();
155
+ emits("openVisitorDataFromScannedQrCodeDialog", data.items[0]);
128
156
  }
129
157
 
130
158
  // window.location.href = qrCodeMessage;
@@ -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.2-staging.79",
5
+ "version": "3.2.2-staging.81",
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": {
@@ -43,8 +43,37 @@ 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";
46
56
 
47
57
  export default defineNuxtPlugin((app) => {
58
+ /**
59
+ * BOOT ON THE REMEMBERED THEME, DON'T CORRECT IT AFTERWARDS.
60
+ *
61
+ * Reading the cookie here, before `createVuetify`, is what makes the choice
62
+ * survive a reload with no flash: Vuetify's first paint is already the right
63
+ * theme, so there is no moment where the wrong one is on screen. Setting it
64
+ * from a component's `onMounted` would repaint the whole application one
65
+ * frame in, which is the flash this is written to avoid.
66
+ *
67
+ * `useThemePreference()` writes this cookie; nothing else does.
68
+ */
69
+ const stored = useCookie<string | null>(
70
+ THEME_COOKIE,
71
+ // typed `unknown` in runtimeConfig; same object `sid` and `user` use
72
+ useRuntimeConfig().public.cookieConfig as CookieOptions<string | null> & {
73
+ readonly?: false;
74
+ }
75
+ ).value;
76
+
48
77
  const vuetify = createVuetify({
49
78
  defaults: {
50
79
  VTextField: {
@@ -97,7 +126,7 @@ export default defineNuxtPlugin((app) => {
97
126
  * `utils/theme.ts`, which `utils/theme.test.ts` measures on every run.
98
127
  */
99
128
  theme: {
100
- defaultTheme: "light",
129
+ defaultTheme: stored === "dark" ? "dark" : "light",
101
130
  themes: {
102
131
  light: LIGHT_THEME,
103
132
  dark: DARK_THEME,
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { statusTone, statusToneClass } from "./status.ts";
5
+
6
+ test("the handoff's mapping, word for word", () => {
7
+ for (const s of ["Completed", "Paid", "Available", "Open"]) {
8
+ assert.equal(statusTone(s), "ok", s);
9
+ }
10
+
11
+ assert.equal(statusTone("Pending"), "warn");
12
+
13
+ for (const s of ["In Use", "Closed", "Checkout"]) {
14
+ assert.equal(statusTone(s), "err", s);
15
+ }
16
+
17
+ assert.equal(statusTone("Role"), "info");
18
+ });
19
+
20
+ /** The same status arrives spelled three ways from three endpoints. */
21
+ test("casing, spacing and underscores do not change a status's colour", () => {
22
+ for (const s of ["in use", "IN USE", "In_Use", "in-use", " In Use "]) {
23
+ assert.equal(statusTone(s), "err", s);
24
+ }
25
+ });
26
+
27
+ /**
28
+ * The important one. A status the design never named must NOT be guessed into
29
+ * a colour - a wrong green on a failed job reads as success. Neutral is
30
+ * visible, honest, and gets it noticed.
31
+ */
32
+ test("an unmapped status comes out neutral rather than guessed", () => {
33
+ for (const s of ["Cancelled", "Overdue", "Draft", "", null, undefined]) {
34
+ assert.equal(statusTone(s), "neutral", String(s));
35
+ }
36
+ });
37
+
38
+ test("the class name matches what tokens.css actually defines", () => {
39
+ assert.equal(statusToneClass("Paid"), "tone-ok");
40
+ assert.equal(statusToneClass("Whatever"), "tone-neutral");
41
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * THE SEMANTIC STATUS MAPPING, IN ONE PLACE.
3
+ *
4
+ * The design assigns a colour to a status WORD rather than to a module, so the
5
+ * same word has to look the same wherever it appears - "Open" on a Daily
6
+ * Occurrence Book and "Open" on a work order are the same green. Keeping the
7
+ * list here rather than in each screen is what makes that true, and is what
8
+ * Phases 3 and 4 read when they restyle the chips.
9
+ *
10
+ * Straight from the handoff:
11
+ * Completed / Paid / Available / Open -> ok
12
+ * Pending -> warn
13
+ * In Use / Closed / Checkout -> err
14
+ * Role and info tags -> info
15
+ *
16
+ * Anything not listed is `neutral` on purpose. A status this product has and
17
+ * the design did not name must NOT be guessed into a colour - a wrong green on
18
+ * a failed job is worse than a grey one - so it comes out neutral and visible,
19
+ * which is how it gets noticed and added.
20
+ */
21
+ export type TStatusTone = "ok" | "warn" | "err" | "info" | "neutral";
22
+
23
+ const TONES: Record<string, TStatusTone> = {
24
+ completed: "ok",
25
+ paid: "ok",
26
+ available: "ok",
27
+ open: "ok",
28
+
29
+ pending: "warn",
30
+
31
+ "in use": "err",
32
+ closed: "err",
33
+ checkout: "err",
34
+
35
+ role: "info",
36
+ info: "info",
37
+ };
38
+
39
+ /**
40
+ * The tone for a status word. Case- and space-insensitive, because the same
41
+ * status arrives as "In Use", "in_use" and "IN USE" from different endpoints.
42
+ */
43
+ export function statusTone(status: string | null | undefined): TStatusTone {
44
+ if (!status) return "neutral";
45
+
46
+ const key = String(status).trim().toLowerCase().replace(/[_-]+/g, " ");
47
+
48
+ return TONES[key] ?? "neutral";
49
+ }
50
+
51
+ /** The class `assets/css/tokens.css` paints that tone with. */
52
+ export function statusToneClass(status: string | null | undefined): string {
53
+ return `tone-${statusTone(status)}`;
54
+ }