@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.
- package/assets/css/shell.css +258 -0
- package/assets/css/tokens.css +220 -0
- package/components/Layout/Header.vue +19 -8
- package/components/Layout/NavigationDrawer.vue +46 -1
- package/components/PassInformation.vue +104 -45
- package/components/ScanVisitorQRCode.vue +35 -7
- package/composables/useThemePreference.ts +63 -0
- package/composables/useVisitor.ts +6 -1
- package/nuxt.config.ts +31 -0
- package/package.json +3 -2
- package/plugins/vuetify.ts +30 -1
- package/utils/status.test.ts +41 -0
- package/utils/status.ts +54 -0
- package/utils/theme.test.ts +325 -7
- package/utils/theme.ts +268 -137
|
@@ -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
|
|
99
|
-
import
|
|
103
|
+
import { errorConverter } from "../utils/data";
|
|
104
|
+
import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
passRules
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
137
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/plugins/vuetify.ts
CHANGED
|
@@ -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
|
+
});
|
package/utils/status.ts
ADDED
|
@@ -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
|
+
}
|