@7365admin1/layer-common 4.2.2-staging.266 → 4.2.2-staging.268

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.
@@ -1,42 +1,54 @@
1
1
  <template>
2
- <div class="d-flex flex-column">
3
- <v-text-field
4
- v-bind="$attrs"
5
- class="filter-field"
6
- ref="dateTimePickerRef"
7
- :model-value="dateTimeFormattedReadOnly"
8
- autocomplete="off"
9
- :placeholder="inputPlaceholder"
10
- :rules="rules"
11
- style="z-index: 10"
12
- @click="openDatePicker"
13
- >
14
- <template #append-inner>
15
- <v-icon icon="mdi-calendar" @click.stop="openDatePicker" />
16
- </template>
17
- </v-text-field>
18
- <div class="w-100 d-flex align-end ga-3 hidden-input">
19
- <input
20
- ref="dateInput"
21
- :type="inputType"
22
- v-model="dateTime"
23
- :min="minValue"
24
- />
25
- </div>
26
- </div>
2
+ <v-text-field
3
+ v-bind="$attrs"
4
+ ref="dateTimePickerRef"
5
+ class="filter-field"
6
+ v-model="fieldValue"
7
+ :type="inputType"
8
+ :label="placeholder || undefined"
9
+ :min="minValue"
10
+ :rules="rules"
11
+ persistent-placeholder
12
+ autocomplete="off"
13
+ />
27
14
  </template>
28
15
 
29
16
  <script setup lang="ts">
30
- import { ref, computed, watch } from "vue";
17
+ import { ref, computed } from "vue";
18
+
19
+ /*
20
+ * The field the user edits IS the native date input now.
21
+ *
22
+ * It used to be two elements: a read-only Vuetify text field for show, and a
23
+ * zero-height `opacity: 0` native input behind it that `showPicker()` was
24
+ * called on. That arrangement broke every way a user can enter a date:
25
+ *
26
+ * - TYPING went into Vuetify's own internal copy of the value (the text field
27
+ * was bound with `:model-value` and nothing listened for the update), so the
28
+ * typed date DISPLAYED and PASSED the required-rule while both models here
29
+ * stayed empty - `startDate: ""` went to the API, and nothing could ever be
30
+ * Upcoming.
31
+ * - PICKING a date drove the invisible input. A partly-filled
32
+ * `datetime-local` has an empty value until every segment is set, and the
33
+ * segments were invisible, so the picker closed and set nothing.
34
+ * - An End Date that was typed stayed invalid for the same reason as the
35
+ * first point, so an announcement with a real end date could not be created
36
+ * at all.
37
+ *
38
+ * One visible native input has the browser's own picker, its own typing, and a
39
+ * real `v-model`, which is all three fixes.
40
+ */
31
41
 
32
42
  const prop = defineProps({
33
43
  rules: {
34
44
  type: Array as PropType<Array<any>>,
35
45
  default: () => [],
36
46
  },
47
+ // Rendered as the field's label: a native date input always draws its own
48
+ // dd/mm/yyyy segments, so a placeholder would never be seen.
37
49
  placeholder: {
38
50
  type: String,
39
- default: "DD/MM/YYYY, HH:MM AM/PM",
51
+ default: "",
40
52
  },
41
53
  dateOnly: {
42
54
  type: Boolean,
@@ -48,11 +60,10 @@ const prop = defineProps({
48
60
  },
49
61
  });
50
62
 
51
- const { formatDateISO8601 } = useUtils();
52
- const dateTime = defineModel<string | null>({ default: null }); //2025-10-10T13:09 format
53
- const dateTimeUTC = defineModel<string | null>("utc", { default: null }); // UTC format
63
+ const dateTime = defineModel<string | null>({ default: null }); // 2025-10-10T13:09, local
64
+ const dateTimeUTC = defineModel<string | null>("utc", { default: null }); // ISO 8601, UTC
54
65
 
55
- const dateTimeFormattedReadOnly = ref<string | null>(null);
66
+ const dateTimePickerRef = ref<HTMLInputElement | null>(null);
56
67
  const inputType = computed(() => (prop.dateOnly ? "date" : "datetime-local"));
57
68
 
58
69
  const minValue = computed(() => {
@@ -62,148 +73,49 @@ const minValue = computed(() => {
62
73
  }
63
74
  return prop.min;
64
75
  });
65
- const inputPlaceholder = computed(
66
- () =>
67
- prop.placeholder ||
68
- (prop.dateOnly ? "MM/DD/YYYY" : "DD/MM/YYYY, HH:MM AM/PM")
69
- );
70
-
71
- const dateInput = ref<HTMLInputElement | null>(null);
72
- const dateTimePickerRef = ref<HTMLInputElement | null>(null);
73
- let nativeInput: HTMLInputElement | null = null;
74
-
75
- const isInitialLoad = ref(true);
76
-
77
- function handleNativeInputClick(e: MouseEvent) {
78
- e.stopPropagation();
79
- openDatePicker();
80
- }
81
-
82
- function openDatePicker() {
83
- setTimeout(() => {
84
- dateInput.value?.showPicker?.();
85
- }, 0);
86
- }
87
76
 
88
77
  function validate() {
89
78
  (dateTimePickerRef.value as any)?.validate();
90
79
  }
91
80
 
92
- function convertToReadableFormat(dateStr: string): string {
93
- if (!dateStr) return "";
94
-
95
- const date = new Date(dateStr);
96
- if (Number.isNaN(date.getTime())) return "";
97
-
98
- const month = String(date.getMonth() + 1).padStart(2, "0");
99
- const day = String(date.getDate()).padStart(2, "0");
100
- const year = date.getFullYear();
81
+ const pad = (n: number) => String(n).padStart(2, "0");
101
82
 
102
- if (prop.dateOnly) {
103
- return `${day}/${month}/${year}`;
83
+ // What the native input accepts: `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`, local time.
84
+ function toInputValue(value: string | null): string {
85
+ if (!value) return "";
86
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
87
+ return prop.dateOnly ? value : `${value}T00:00`;
104
88
  }
105
-
106
- let hours = date.getHours();
107
- const minutes = String(date.getMinutes()).padStart(2, "0");
108
-
109
- const ampm = hours >= 12 ? "PM" : "AM";
110
- hours = hours % 12;
111
- hours = hours ? hours : 12;
112
-
113
- const formattedTime = `${String(hours).padStart(2, "0")}:${minutes} ${ampm}`;
114
-
115
- return `${day}/${month}/${year}, ${formattedTime}`;
116
- }
117
-
118
- function toDateOnlyInputValue(dateStr: string): string {
119
- if (!dateStr) return "";
120
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
121
- const date = new Date(dateStr);
89
+ const date = new Date(value);
122
90
  if (Number.isNaN(date.getTime())) return "";
123
- const year = date.getFullYear();
124
- const month = String(date.getMonth() + 1).padStart(2, "0");
125
- const day = String(date.getDate()).padStart(2, "0");
126
- return `${year}-${month}-${day}`;
91
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
92
+ return prop.dateOnly
93
+ ? day
94
+ : `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
127
95
  }
128
96
 
129
- function handleInitialDate() {
130
- const dateDefault = dateTime.value;
131
- const dateUTC = dateTimeUTC.value;
132
- if (dateDefault) {
133
- dateTimeFormattedReadOnly.value = convertToReadableFormat(dateDefault);
134
- if (prop.dateOnly) {
135
- dateTimeUTC.value = toDateOnlyInputValue(dateDefault);
136
- } else {
137
- const localDate = new Date(dateDefault);
138
- dateTimeUTC.value = localDate.toISOString();
139
- }
140
- } else if (dateUTC) {
141
- dateTimeFormattedReadOnly.value = convertToReadableFormat(dateUTC);
142
- if (prop.dateOnly) {
143
- dateTime.value = toDateOnlyInputValue(dateUTC);
144
- } else {
145
- const localDate = new Date(dateUTC);
146
- dateTime.value = formatDateISO8601(localDate);
147
- }
148
- } else {
149
- dateTimeFormattedReadOnly.value = null;
150
- }
97
+ // What the API stores: a date-only field stays `YYYY-MM-DD`, everything else
98
+ // becomes UTC.
99
+ function toUTCValue(value: string | null): string | null {
100
+ if (!value) return null;
101
+ if (prop.dateOnly) return toInputValue(value).slice(0, 10) || null;
102
+ const date = new Date(value);
103
+ if (Number.isNaN(date.getTime())) return null;
104
+ return date.toISOString();
151
105
  }
152
106
 
153
- watch(
154
- dateTime,
155
- (dateVal) => {
156
- if (isInitialLoad.value) return; // ignore the first run
157
- if (!dateVal) {
158
- dateTimeFormattedReadOnly.value = null;
159
- dateTimeUTC.value = null;
160
- return;
161
- }
162
-
163
- dateTimeFormattedReadOnly.value = convertToReadableFormat(dateVal);
164
- if (prop.dateOnly) {
165
- dateTimeUTC.value = toDateOnlyInputValue(dateVal);
166
- } else {
167
- const localDate = new Date(dateVal);
168
- dateTimeUTC.value = localDate.toISOString();
169
- }
170
- },
171
- { immediate: false }
172
- );
173
-
174
- watch(
175
- dateTimeUTC,
176
- () => {
177
- handleInitialDate();
107
+ // The one value the field is bound to. A getter and a setter instead of a pair
108
+ // of watchers: no initial-load flag, no chance of the two models fighting each
109
+ // other, and it behaves the same on the server as in the browser.
110
+ const fieldValue = computed({
111
+ get: () => toInputValue(dateTime.value || dateTimeUTC.value),
112
+ set: (value: string | null) => {
113
+ dateTime.value = value || null;
114
+ dateTimeUTC.value = toUTCValue(value);
178
115
  },
179
- { immediate: true }
180
- );
181
-
182
- onMounted(async () => {
183
- await nextTick();
184
- isInitialLoad.value = false;
185
- // Wait until Vuetify renders its internal input
186
- nativeInput = (dateTimePickerRef.value as any)?.$el?.querySelector(
187
- "input"
188
- );
189
- if (nativeInput) {
190
- nativeInput.addEventListener("click", handleNativeInputClick);
191
- }
192
- });
193
-
194
- onBeforeUnmount(() => {
195
- nativeInput?.removeEventListener("click", handleNativeInputClick);
196
- nativeInput = null;
197
116
  });
198
117
 
199
118
  defineExpose({
200
119
  validate,
201
120
  });
202
121
  </script>
203
-
204
- <style scoped>
205
- .hidden-input {
206
- opacity: 0;
207
- height: 0;
208
- }
209
- </style>
@@ -411,6 +411,7 @@
411
411
  </template>
412
412
 
413
413
  <script setup lang="ts">
414
+ import { errorConverter } from "../utils/data";
414
415
  const prop = defineProps({
415
416
  type: {
416
417
  type: String as PropType<TVehicleType | "seasonpass">,
@@ -865,12 +866,11 @@ async function submit() {
865
866
  // A refused create is fail-closed on the server and its message names the
866
867
  // camera that would not take the plate. `$fetch` puts it on `data`, `ofetch`
867
868
  // on `response._data`; reading only one of them dropped the reason.
868
- errorMessage.value =
869
- error?.data?.message ||
870
- error?.response?._data?.message ||
871
- `Failed to ${
872
- prop.mode === "add" ? "add" : "update"
873
- } vehicle. Please try again.`;
869
+ // `errorConverter` reads both shapes and, when the failure never reached
870
+ // the API at all (a gateway 502 answers with HTML, a dropped connection
871
+ // with nothing), returns a plain sentence instead of ofetch's
872
+ // `[POST] "/api/vehicles": 502`.
873
+ errorMessage.value = errorConverter(error);
874
874
  } finally {
875
875
  processing.value = false;
876
876
  }
@@ -210,6 +210,11 @@ import {
210
210
  vehiclePlateActions,
211
211
  type VehicleActionKey,
212
212
  } from '../utils/vehicle-actions';
213
+ // The app-wide error mapper. A gateway 502 answers with an HTML page, not a
214
+ // JSON API error, so reading `_data.message` and then falling back to
215
+ // `error.message` put ofetch's own developer string in the delete dialog:
216
+ // `[PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502`.
217
+ import { errorConverter } from '../utils/data';
213
218
 
214
219
 
215
220
 
@@ -289,21 +294,6 @@ function closeActionDialog(key: "approveVehicle" | "restoreVehicle" | "updateVeh
289
294
  actionError.value = "";
290
295
  }
291
296
 
292
- /**
293
- * The server's own sentence, or a plain fallback.
294
- *
295
- * `error.response._data` is undefined on a network failure, so reading it
296
- * unguarded threw out of the catch and the operator saw nothing at all.
297
- */
298
- function readServerMessage(error: any, fallback: string) {
299
- return (
300
- error?.response?._data?.message ||
301
- error?.data?.message ||
302
- error?.message ||
303
- fallback
304
- );
305
- }
306
-
307
297
  function showMessage(msg: string, color: string) {
308
298
  message.value = msg;
309
299
  messageColor.value = color;
@@ -566,7 +556,7 @@ async function submitDelete() {
566
556
  // request is refused. Say so. This used to assign to `message` WITHOUT
567
557
  // raising the snackbar - and `error.response._data` throws on a network
568
558
  // error - so a refused delete showed the operator nothing at all.
569
- deleteError.value = readServerMessage(error, "Failed to delete vehicle.");
559
+ deleteError.value = errorConverter(error);
570
560
  // The dialog stays open and the row stays in the table - nothing is removed
571
561
  // optimistically, so what is on screen still matches the database.
572
562
  } finally {
@@ -595,7 +585,7 @@ async function submitRestore() {
595
585
  showMessage(res.message, "success");
596
586
  await getVehiclesRefresh();
597
587
  } catch (error: any) {
598
- actionError.value = readServerMessage(error, "Failed to restore vehicle.");
588
+ actionError.value = errorConverter(error);
599
589
  } finally {
600
590
  restoringVehicle.value = false;
601
591
  }
@@ -642,7 +632,7 @@ async function submitUpdateType() {
642
632
  showMessage(res.message, "success");
643
633
  await getVehiclesRefresh();
644
634
  } catch (error: any) {
645
- actionError.value = readServerMessage(error, "Failed to update vehicle type.");
635
+ actionError.value = errorConverter(error);
646
636
  } finally {
647
637
  updatingType.value = false;
648
638
  }
@@ -667,7 +657,7 @@ async function submitApprove() {
667
657
  showMessage(res.message, "success");
668
658
  await getVehiclesRefresh();
669
659
  } catch (error: any) {
670
- actionError.value = readServerMessage(error, "Failed to approve vehicle.");
660
+ actionError.value = errorConverter(error);
671
661
  } finally {
672
662
  approvingVehicle.value = false;
673
663
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.2.2-staging.266",
5
+ "version": "4.2.2-staging.268",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
package/utils/data.ts CHANGED
@@ -1,33 +1,113 @@
1
+ /**
2
+ * The app-wide "turn a failed request into a sentence" helper. 29 call sites in
3
+ * this layer alone, so every module gets whatever this returns.
4
+ *
5
+ * It only ever understood a JSON API error - `{ message: "..." }` from our own
6
+ * Express error handler. Anything else fell through to a developer string. A
7
+ * gateway 502 put this in front of an operator, verbatim, inside the delete
8
+ * dialog:
9
+ *
10
+ * [PUT] "/api/vehicles/6a9691f25648d7633171c9e3": 502
11
+ *
12
+ * That is ofetch's own `error.message`. It names an internal route and a record
13
+ * id, and tells the person nothing they can do. nginx and Cloudflare answer
14
+ * with HTML, not JSON, so `_data` has no `.message` at all - and on a dropped
15
+ * connection there is no `response` either.
16
+ *
17
+ * The rules below, in order:
18
+ * 1. our own API said something -> say exactly that, unchanged. This is the
19
+ * path every working screen already takes and it must not move.
20
+ * 2. otherwise -> a plain sentence chosen from the status.
21
+ * 3. never -> HTML, a JSON dump, an ofetch `[METHOD] "url": status` string,
22
+ * or a raw object.
23
+ */
24
+
25
+ /** ofetch builds `[GET] "/api/thing": 502 Bad Gateway`. Never show it. */
26
+ const OFETCH_MESSAGE = /^\[[A-Z]+\]\s+"/;
27
+
28
+ /** A body that is a document, not an API error. */
29
+ const LOOKS_LIKE_HTML = /^\s*(<!doctype|<html|<)/i;
30
+
31
+ const GENERIC =
32
+ "Something went wrong. Please try again or contact support if the problem persists.";
33
+
34
+ /**
35
+ * The server's own sentence, if there is one that is safe to show.
36
+ *
37
+ * Returns `""` when the body is not one of ours - an HTML error page, a JSON
38
+ * object with no `message`, or an empty response.
39
+ */
40
+ function apiMessage(error: any): string {
41
+ const data = error?.response?._data ?? error?.data ?? error?._data;
42
+
43
+ const candidate =
44
+ typeof data === "string" ? data : (data?.message ?? data?.error);
45
+
46
+ if (typeof candidate !== "string") return "";
47
+
48
+ const text = candidate.trim();
49
+ if (!text) return "";
50
+ if (LOOKS_LIKE_HTML.test(text)) return "";
51
+ // A whole HTML page that starts with whitespace/JSON-ish noise still has no
52
+ // business on screen; anything this long is a document, not a message.
53
+ if (text.length > 400) return "";
54
+
55
+ return text;
56
+ }
57
+
58
+ /** A plain sentence for a request that failed without telling us anything. */
59
+ function sentenceForStatus(status: number | null): string {
60
+ if (status === null) {
61
+ // No response at all: DNS, a dropped connection, a cancelled request.
62
+ return "Could not reach the server. Check your internet connection and try again.";
63
+ }
64
+ if (status === 401) return "Your session has expired. Please sign in again.";
65
+ if (status === 403) {
66
+ return "You do not have permission to do this. Ask your iService365 administrator for access.";
67
+ }
68
+ if (status === 404) return "That record no longer exists. Refresh the page and try again.";
69
+ if (status === 408 || status === 504) {
70
+ return "The server took too long to answer. Please try again in a few moments.";
71
+ }
72
+ if (status === 429) return "Too many attempts in a short time. Please wait a moment and try again.";
73
+ if (status === 502 || status === 503) {
74
+ return "The server is temporarily unavailable. Please try again in a few moments.";
75
+ }
76
+ if (status >= 500) {
77
+ return "The server could not complete this request. Please try again, and contact support if it keeps happening.";
78
+ }
79
+ return GENERIC;
80
+ }
81
+
1
82
  export const errorConverter = (data: any): string => {
2
- let error =
3
- "Something went wrong. Please try again or contact support if the problem persists.";
4
-
5
- if (data?.response) {
6
- console.log("errorConverter response: ", data.response);
7
- if (
8
- data.response.status == 400 ||
9
- data.response.status == 500 ||
10
- data.response.status == 502 ||
11
- (data.response.status == 404 && data.response._data.message)
12
- ) {
13
- if (data.response._data) {
14
- return (error = data.response._data.message || data.response._data);
15
- }
16
- } else if (data.response._data) {
17
- return (error = data.response._data.message || data.response._data);
18
- } else if (data.response.status == 404) {
19
- return "Server Endpoint Not Found";
20
- } else if (data.response.status == 502) {
21
- return "The server is temporarily unavailable. Please try again in a few moments.";
22
- }
23
- error = `Error ${data.response.status}: ${JSON.stringify(
24
- data.response._data
25
- )}`;
26
- } else if (data?.message) {
27
- console.log("converter: ", data);
28
- error = data?.message;
83
+ // 1. Our own API's sentence wins, whatever the status is. Unchanged
84
+ // behaviour for every screen that works today.
85
+ const fromApi = apiMessage(data);
86
+ if (fromApi) return fromApi;
87
+
88
+ // 2. No usable body. Decide from the status alone.
89
+ const status =
90
+ data?.response?.status ?? data?.statusCode ?? data?.status ?? null;
91
+
92
+ if (typeof status === "number") return sentenceForStatus(status);
93
+
94
+ // 3. No status either. An ofetch error with no response means the request
95
+ // never completed at all - DNS, a dropped connection, a cancelled
96
+ // request - and its `message` is the developer string that leaked.
97
+ const message = typeof data?.message === "string" ? data.message.trim() : "";
98
+
99
+ if (OFETCH_MESSAGE.test(message) || data?.name === "FetchError") {
100
+ return sentenceForStatus(null);
101
+ }
102
+
103
+ // 4. A plain `throw new Error("...")` from our own code. Several of those are
104
+ // written FOR the toast (see `findServiceProviderIdBySite` below), so they
105
+ // still pass through unchanged.
106
+ if (message && message.length <= 400 && !LOOKS_LIKE_HTML.test(message)) {
107
+ return message;
29
108
  }
30
- return error;
109
+
110
+ return GENERIC;
31
111
  };
32
112
 
33
113
  /**