@7365admin1/layer-common 3.2.2-staging.193 → 3.2.2-staging.195

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.
@@ -62,6 +62,15 @@
62
62
  klass="app-toolbar__search"
63
63
  />
64
64
  </template>
65
+
66
+ <!--
67
+ A failed request is not a site with no access cards. Without this the
68
+ table drew its neutral "No data available" for a 500, which reads as
69
+ "there are no cards here" - a statement the server never made.
70
+ -->
71
+ <template v-if="getCardError" #no-data>
72
+ <DashboardEmptyState error />
73
+ </template>
65
74
  </TableMain>
66
75
 
67
76
  <!-- Create Dialog -->
@@ -125,6 +134,10 @@
125
134
  </v-row>
126
135
  </template>
127
136
  <script setup lang="ts">
137
+ // Relative import, never Nuxt auto-import: this layer's `utils/` are auto-imported
138
+ // into the consuming apps but not reliably into the layer's own components.
139
+ import { readListPage } from "../utils/list-page";
140
+
128
141
  definePageMeta({
129
142
  middleware: ["01-auth", "02-org"],
130
143
  memberOnly: true,
@@ -262,6 +275,7 @@ const {
262
275
  data: getCardReq,
263
276
  refresh: getCards,
264
277
  status: getAllReqStatus,
278
+ error: getCardError,
265
279
  } = useLazyAsyncData(
266
280
  "get-all-cards",
267
281
  () =>
@@ -288,21 +302,27 @@ watch(searchText, (val) => {
288
302
 
289
303
  const loading = computed(() => getAllReqStatus.value === "pending");
290
304
 
305
+ // This endpoint answers with a NESTED envelope - `{ data: { items, pages,
306
+ // pageRange } }` - so the page totals are read off `reply.data`, through the
307
+ // one helper that knows how a paginated reply is shaped. The totals are the
308
+ // server's count of the WHOLE list; they are never re-derived from the rows on
309
+ // this page. Applying it unconditionally matters: when a later request fails,
310
+ // the reply goes null and the header falls back to the placeholder instead of
311
+ // keeping the previous page's total on screen as if it were still true.
291
312
  watchEffect(() => {
292
- if (getCardReq.value?.data) {
293
- items.value = getCardReq.value.data.items.map((item: any) => ({
294
- ...item,
295
- cardCounts: {
296
- ...item.cardCounts,
297
- available: {
298
- ...item.cardCounts?.available,
299
- physical: (item.available?.physical ?? []).filter((c: any) => !c.damage).length,
300
- },
313
+ const listPage = readListPage<Record<string, any>>(getCardReq.value?.data);
314
+ items.value = listPage.items.map((item: any) => ({
315
+ ...item,
316
+ cardCounts: {
317
+ ...item.cardCounts,
318
+ available: {
319
+ ...item.cardCounts?.available,
320
+ physical: (item.available?.physical ?? []).filter((c: any) => !c.damage).length,
301
321
  },
302
- }));
303
- pages.value = getCardReq.value.data.pages;
304
- pageRange.value = getCardReq.value.data.pageRange;
305
- }
322
+ },
323
+ }));
324
+ pages.value = listPage.pages;
325
+ pageRange.value = listPage.pageRange;
306
326
  });
307
327
 
308
328
  function setCard({ mode = "create", dialog = true, data = {} as TCard } = {}) {
@@ -353,7 +353,9 @@ const buildingFilesNames = ref<Record<string, string>>({});
353
353
  building.value.site = prop.site;
354
354
  if (prop.mode === "edit") {
355
355
  building.value = JSON.parse(JSON.stringify(prop.building));
356
- building.value.levels = building.value.levels.map((level: any) => ({ _id: level._id, name: level.name })); //temporary
356
+ // Same missing field as the blocks table: a block record without `levels`
357
+ // made this `.map` throw, which killed the edit dialog before it opened.
358
+ building.value.levels = (building.value.levels ?? []).map((level: any) => ({ _id: level._id, name: level.name })); //temporary
357
359
  buildingLevels.value = building.value.levels.length;
358
360
  initialBuildingLevels.value = JSON.parse(JSON.stringify(building.value.levels));
359
361
 
@@ -25,8 +25,13 @@
25
25
  <template #item.createdAt="{ value }">
26
26
  {{ new Date(value).toLocaleDateString() }}
27
27
  </template>
28
+ <!--
29
+ `item.levels.length` threw during render for any block whose record has
30
+ no `levels` array, and a TypeError in a row template tears the whole
31
+ table down - one incomplete block blanked every block.
32
+ -->
28
33
  <template #item.levels="{ item }">
29
- {{ item.levels.length }}
34
+ {{ levelCount(item) }}
30
35
  </template>
31
36
  </TableMain>
32
37
 
@@ -58,7 +63,9 @@
58
63
 
59
64
  <v-col cols="12">
60
65
  <strong>Levels:</strong>
61
- {{ selectedBuilding?.levels.length ?? "N/A" }}
66
+ <!-- The optional chain stopped one property early: `?.levels.length`
67
+ still reads `.length` of undefined when the block has no levels. -->
68
+ {{ selectedBuilding ? levelCount(selectedBuilding) : "N/A" }}
62
69
  </v-col>
63
70
  <v-col cols="12">
64
71
  <strong>Files:</strong>
@@ -151,6 +158,10 @@
151
158
  </template>
152
159
 
153
160
  <script setup lang="ts">
161
+ // Relative import, never Nuxt auto-import: this layer's `utils/` are auto-imported
162
+ // into the consuming apps but not reliably into the layer's own components.
163
+ import { levelCount } from "../../utils/data";
164
+
154
165
  definePageMeta({
155
166
  middleware: ["01-auth", "02-org"],
156
167
  memberOnly: true,
@@ -85,6 +85,15 @@
85
85
  @update:page="page = $event"
86
86
  @row-click="tableRowClickHandler"
87
87
  >
88
+ <!--
89
+ A failed request is not an empty folder. Without this the table drew
90
+ its neutral "No data available" for a 500, which reads as "there are
91
+ no documents here" - a statement the server never made.
92
+ -->
93
+ <template v-if="getDocumentError" #no-data>
94
+ <DashboardEmptyState error />
95
+ </template>
96
+
88
97
  <template v-if="isInsideFolder" #breadcrumb>
89
98
  <div class="d-flex align-center flex-wrap ga-1">
90
99
  <v-btn
@@ -597,6 +606,10 @@
597
606
  </v-row>
598
607
  </template>
599
608
  <script setup lang="ts">
609
+ // Relative import, never Nuxt auto-import: this layer's `utils/` are auto-imported
610
+ // into the consuming apps but not reliably into the layer's own components.
611
+ import { readListPage } from "../utils/list-page";
612
+
600
613
  definePageMeta({
601
614
  middleware: ["01-auth", "02-org"],
602
615
  memberOnly: true,
@@ -736,6 +749,7 @@ const {
736
749
  data: getDocumentReq,
737
750
  refresh: getDocuments,
738
751
  status: getAllReqStatus,
752
+ error: getDocumentError,
739
753
  } = useLazyAsyncData(
740
754
  "get-all-documents",
741
755
  () =>
@@ -754,12 +768,16 @@ const {
754
768
 
755
769
  const loading = computed(() => getAllReqStatus.value === "pending");
756
770
 
771
+ // The page totals are the server's count of the WHOLE list, read through the
772
+ // one helper that knows how a paginated reply is shaped - never re-derived
773
+ // from the rows on this page. Applied unconditionally so that when a request
774
+ // fails the reply goes null and the header falls back to the placeholder,
775
+ // instead of leaving the previous page's total on screen as if it still held.
757
776
  watchEffect(() => {
758
- if (getDocumentReq.value) {
759
- items.value = getDocumentReq.value.items;
760
- pages.value = getDocumentReq.value.pages;
761
- pageRange.value = getDocumentReq.value.pageRange;
762
- }
777
+ const listPage = readListPage<Record<string, any>>(getDocumentReq.value);
778
+ items.value = listPage.items;
779
+ pages.value = listPage.pages;
780
+ pageRange.value = listPage.pageRange;
763
781
  });
764
782
 
765
783
  function formatDate(value?: string | null) {
@@ -29,6 +29,15 @@
29
29
  :show-header="true"
30
30
  @refresh="getUnitCleanerChecklistRefresh"
31
31
  >
32
+ <!--
33
+ A failed request is not a schedule with no checklist. Without this the
34
+ table drew its neutral "No checklist found" for a 500, which reads as
35
+ "nothing is scheduled here" - a statement the server never made.
36
+ -->
37
+ <template v-if="getUnitCleanerChecklistError" #no-data>
38
+ <DashboardEmptyState error />
39
+ </template>
40
+
32
41
  <!-- Status icon driven by local activeActions state -->
33
42
  <template #item-prepend="{ item, group }">
34
43
  <!--
@@ -441,6 +450,7 @@
441
450
  </template>
442
451
 
443
452
  <script setup lang="ts">
453
+ import { readListPage } from "../utils/list-page";
444
454
  import useCleaningSchedules from "../composables/useCleaningSchedules";
445
455
  import useCustomerSite from "../composables/useCustomerSite";
446
456
  import useFile from "../composables/useFile";
@@ -651,6 +661,7 @@ const {
651
661
  data: getUnitCleanerChecklistReq,
652
662
  refresh: getUnitCleanerChecklistRefresh,
653
663
  pending: loading,
664
+ error: getUnitCleanerChecklistError,
654
665
  } = await useLazyAsyncData(
655
666
  `get-all-unit-cleaner-checklist`,
656
667
  () =>
@@ -675,13 +686,20 @@ watchEffect(() => {
675
686
  }
676
687
  });
677
688
 
689
+ // The page totals are the server's count of the WHOLE list, read through the
690
+ // one helper that knows how a paginated reply is shaped - never re-derived
691
+ // from the rows on this page. Applied unconditionally so that when a request
692
+ // fails the reply goes null and the header falls back to the placeholder,
693
+ // instead of leaving the previous page's total on screen as if it still held.
694
+ // `page` still only moves when the server states it, so a failed request does
695
+ // not knock the reader back to page 1.
678
696
  watchEffect(() => {
679
- if (getUnitCleanerChecklistReq.value) {
680
- items.value = getUnitCleanerChecklistReq.value.items;
697
+ const listPage = readListPage<TChecklistSet>(getUnitCleanerChecklistReq.value);
698
+ items.value = listPage.items;
699
+ if (getUnitCleanerChecklistReq.value?.page)
681
700
  page.value = getUnitCleanerChecklistReq.value.page;
682
- pages.value = getUnitCleanerChecklistReq.value.pages;
683
- pageRange.value = getUnitCleanerChecklistReq.value.pageRange;
684
- }
701
+ pages.value = listPage.pages;
702
+ pageRange.value = listPage.pageRange;
685
703
  });
686
704
 
687
705
  watchEffect(() => {
@@ -88,10 +88,9 @@
88
88
  @update:model-value="
89
89
  toggleCategory(String(resourceKey), $event)
90
90
  "
91
- color="accent"
91
+ class="app-switch"
92
92
  density="compact"
93
93
  hide-details
94
- inset
95
94
  />
96
95
  </template>
97
96
  </v-list-item>
@@ -47,10 +47,9 @@
47
47
  <v-switch
48
48
  :model-value="isCategoryEnabled(String(resourceKey))"
49
49
  :disabled="!edit"
50
- color="accent"
50
+ class="app-switch"
51
51
  density="compact"
52
52
  hide-details
53
- inset
54
53
  @update:model-value="
55
54
  toggleCategory(String(resourceKey), $event)
56
55
  "
@@ -84,6 +84,7 @@
84
84
  :permissions="props.permissions"
85
85
  :org="props.orgId"
86
86
  :site-id="props.siteId"
87
+ :site-state="siteFieldShown"
87
88
  :web-only-resources="props.webOnlyResources"
88
89
  @success="success()"
89
90
  :type="props.type"
@@ -184,6 +185,9 @@
184
185
  </template>
185
186
 
186
187
  <script setup lang="ts">
188
+ // Relative import: this layer's `utils/` is not auto-imported into consumers.
189
+ import { showSiteField } from "../utils/role";
190
+
187
191
  const props = defineProps({
188
192
  orgId: {
189
193
  type: String,
@@ -197,6 +201,18 @@ const props = defineProps({
197
201
  type: String,
198
202
  default: "",
199
203
  },
204
+ /*
205
+ * Whether the Create-role dialog shows its (read-only) Site field.
206
+ * The dialog defaulted this to `true` but this component never declared or
207
+ * forwarded it, and its root is a `<div>`, so a fallthrough `site-state`
208
+ * attribute landed on that div instead of the dialog - no consuming app
209
+ * could switch the field off. Admin roles are never site-scoped, so the
210
+ * field drew permanently disabled and empty there (QA, admin PR #18).
211
+ */
212
+ siteState: {
213
+ type: Boolean,
214
+ default: true,
215
+ },
200
216
  type: {
201
217
  type: String,
202
218
  required: true,
@@ -296,6 +312,10 @@ function tableRowClickHandler(_: any, data: any) {
296
312
 
297
313
  const createDialog = ref(false);
298
314
 
315
+ const siteFieldShown = computed(() =>
316
+ showSiteField(props.siteState, props.type)
317
+ );
318
+
299
319
  function success() {
300
320
  createDialog.value = false;
301
321
  getRoles();
@@ -62,8 +62,15 @@
62
62
  class="overflow-y-auto"
63
63
  >
64
64
  <div v-if="groupedItems.length === 0" class="table-card__empty">
65
- <v-icon icon="mdi-magnify-close" size="32" />
66
- <span>{{ noDataText }}</span>
65
+ <!--
66
+ The empty block was hard-coded, so a caller had no way to say
67
+ "this is not empty, the request failed" - every 500 rendered as
68
+ the neutral "nothing here". The default is unchanged.
69
+ -->
70
+ <slot name="no-data">
71
+ <v-icon icon="mdi-magnify-close" size="32" />
72
+ <span>{{ noDataText }}</span>
73
+ </slot>
67
74
  </div>
68
75
 
69
76
  <template
@@ -96,6 +96,16 @@
96
96
  </td>
97
97
  </tr>
98
98
  </template>
99
+
100
+ <!--
101
+ Forwarded so a caller can say "this is not empty, the request
102
+ failed". Without it every 500 rendered as the table's neutral
103
+ "No data available". Vuetify's own default is unchanged when the
104
+ caller passes nothing.
105
+ -->
106
+ <template v-if="$slots['no-data']" #no-data>
107
+ <slot name="no-data" />
108
+ </template>
99
109
  </v-data-table>
100
110
  </AppCard>
101
111
  </div>
@@ -13,6 +13,15 @@
13
13
  @click:create="emit('click:create')"
14
14
  :length="pages"
15
15
  >
16
+ <!--
17
+ A failed request is not a site with no work orders. Without this the
18
+ table drew its neutral "No data available" for a 500, which reads as
19
+ "there is nothing outstanding" - a statement the server never made.
20
+ -->
21
+ <template v-if="failed" #no-data>
22
+ <DashboardEmptyState error />
23
+ </template>
24
+
16
25
  <template #title>
17
26
  <span class="screen-card-title">Work Order</span>
18
27
  </template>
@@ -52,6 +61,8 @@
52
61
  </template>
53
62
 
54
63
  <script lang="ts" setup>
64
+ import { readListPage, NO_PAGE_RANGE } from "../../utils/list-page";
65
+
55
66
  const emit = defineEmits(["click:create", "update:pagination"]);
56
67
 
57
68
  const { page, pages, pageRange, workOrders, getWorkOrders } = useWorkOrder();
@@ -61,6 +72,7 @@ const message = ref("");
61
72
  const messageColor = ref("");
62
73
  const messageSnackbar = ref(false);
63
74
  const loading = ref(false);
75
+ const failed = ref(false);
64
76
  const height = ref("500px");
65
77
  const selected = ref<string[]>([]);
66
78
 
@@ -72,20 +84,40 @@ const headers = ref([
72
84
  { title: "Date", value: "createdAt" },
73
85
  ]);
74
86
 
87
+ // `getWorkOrders` RETURNS the reply; nothing was ever reading it. `workOrders`,
88
+ // `pages` and `pageRange` are `useState` cells that no code in this layer or in
89
+ // any of the eleven apps ever assigned, so this table drew zero rows and
90
+ // "-- - -- of --" no matter what the server sent. The reply is now bound
91
+ // through the one helper that knows how a paginated reply is shaped, so the
92
+ // count on screen is the server's count of the whole list - never the number
93
+ // of rows on this page.
75
94
  async function _getWorkOrders() {
76
95
  loading.value = true;
96
+ failed.value = false;
77
97
 
78
98
  try {
79
- await getWorkOrders({
80
- page: page.value,
81
- });
99
+ const listPage = readListPage<TWorkOrder>(
100
+ await getWorkOrders({
101
+ page: page.value,
102
+ })
103
+ );
104
+ workOrders.value = listPage.items;
105
+ pages.value = listPage.pages;
106
+ pageRange.value = listPage.pageRange;
82
107
 
83
108
  emit("update:pagination", {
84
109
  page: page.value,
85
110
  pages: pages.value,
86
111
  });
87
112
  } catch (error) {
88
- console.error("Error fetching feedbacks:", error);
113
+ // Say the request failed rather than presenting it as an empty list. The
114
+ // count goes back to the placeholder for the same reason: a failure is not
115
+ // a zero.
116
+ failed.value = true;
117
+ workOrders.value = [];
118
+ pages.value = 0;
119
+ pageRange.value = NO_PAGE_RANGE;
120
+ console.error("Error fetching work orders:", error);
89
121
  } finally {
90
122
  loading.value = false;
91
123
  }
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.193",
5
+ "version": "3.2.2-staging.195",
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.",
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
- import { findServiceProviderIdBySite } from "./data.ts";
4
+ import { findServiceProviderIdBySite, levelCount } from "./data.ts";
5
5
 
6
6
  const SITE = "68f05fcc0c11062938dadada";
7
7
  const OTHER_SITE = "68f05fcc0c11062938d00001";
@@ -70,3 +70,26 @@ test("an account with no provider list still gets the sentence, not a TypeError"
70
70
  (error: any) => error.constructor === Error && !/undefined/.test(error.message)
71
71
  );
72
72
  });
73
+
74
+ /**
75
+ * A block whose record carries no `levels` array used to throw during render
76
+ * (`Cannot read properties of undefined (reading 'length')`), which blanks the
77
+ * entire blocks table - not just the offending row.
78
+ */
79
+ test("a block with no levels field counts as zero instead of throwing", () => {
80
+ assert.equal(levelCount({ levels: undefined }), 0);
81
+ assert.equal(levelCount({}), 0);
82
+ assert.equal(levelCount(undefined), 0);
83
+ assert.equal(levelCount(null), 0);
84
+ });
85
+
86
+ test("a block with no levels field is not confused with a block that has none", () => {
87
+ assert.equal(levelCount({ levels: [] }), 0);
88
+ assert.equal(levelCount({ levels: [{ name: "1" }, { name: "2" }] }), 2);
89
+ });
90
+
91
+ test("a non-array levels value is counted as zero, not as its own length", () => {
92
+ // The API has been seen to answer with a count rather than the array.
93
+ assert.equal(levelCount({ levels: 7 as unknown as [] }), 0);
94
+ assert.equal(levelCount({ levels: "12" as unknown as [] }), 0);
95
+ });
package/utils/data.ts CHANGED
@@ -160,3 +160,18 @@ export const generateRefNumber = () => {
160
160
  const ref = (timestamp + random).substring(0, 10).toUpperCase();
161
161
  return ref;
162
162
  };
163
+
164
+ /**
165
+ * HOW MANY LEVELS A BLOCK HAS.
166
+ *
167
+ * `levels` is optional on a building record - a block created before levels
168
+ * were captured, or one the list endpoint returns without them, simply has no
169
+ * such field. Two screens read `building.levels.length` directly, which throws
170
+ * a TypeError DURING RENDER: Vue tears the component down and the whole blocks
171
+ * table disappears, so one incomplete row blanks every other row with it.
172
+ *
173
+ * Counting in one place means both screens - and anything added later - answer
174
+ * "no levels recorded" with a zero instead of taking the table down.
175
+ */
176
+ export const levelCount = (building?: { levels?: unknown } | null): number =>
177
+ Array.isArray(building?.levels) ? building.levels.length : 0;
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { test } from "node:test";
4
+
5
+ import { showSiteField } from "./role.ts";
6
+
7
+ /**
8
+ * QA on the admin app (PR #18) reported "no site options" on Create Role. The
9
+ * Site field is read-only by design - it shows the site a role belongs to -
10
+ * but an admin role has no site, so it drew permanently disabled and empty.
11
+ * `RolePermissionFormCreate` defaulted `siteState` to `true` and
12
+ * `RolePermissionMain` neither declared nor forwarded it, and its root is a
13
+ * `<div>`, so a fallthrough attribute could not reach the dialog either: no
14
+ * consuming app could switch the field off.
15
+ */
16
+ test("an admin role never shows the site field", () => {
17
+ assert.equal(showSiteField(true, "admin"), false);
18
+ assert.equal(showSiteField(false, "admin"), false);
19
+ });
20
+
21
+ test("every other role type keeps what the app asked for", () => {
22
+ for (const type of [
23
+ "app",
24
+ "organization",
25
+ "security_agency",
26
+ "cleaning_services",
27
+ ]) {
28
+ assert.equal(showSiteField(true, type), true, type);
29
+ assert.equal(showSiteField(false, type), false, type);
30
+ }
31
+ });
32
+
33
+ test("RolePermissionMain declares site-state and forwards it to the dialog", () => {
34
+ const src = readFileSync(
35
+ new URL("../components/RolePermissionMain.vue", import.meta.url),
36
+ "utf8"
37
+ );
38
+
39
+ assert.match(src, /siteState:\s*\{/, "site-state must be a declared prop");
40
+ assert.match(
41
+ src,
42
+ /:site-state="siteFieldShown"/,
43
+ "the dialog must receive the resolved value, not a fallthrough attribute"
44
+ );
45
+ });
46
+
47
+ /**
48
+ * The category expand toggle carried no class, so it fell back to raw Vuetify
49
+ * `inset` styling: a white thumb on a white card, measured 1.02:1 light /
50
+ * 1.04:1 dark against the dialog surface. `.app-switch` (primitives.css) is
51
+ * this layer's switch shape and is what makes the thumb visible. `inset` is
52
+ * removed with it - Vuetify's inset dimensions beat the `.app-switch` rules on
53
+ * source order and collapse the control to zero width.
54
+ */
55
+ for (const file of [
56
+ "RolePermissionFormCreate.vue",
57
+ "RolePermissionFormPreviewUpdate.vue",
58
+ ]) {
59
+ test(`${file} styles its category toggle with .app-switch`, () => {
60
+ const src = readFileSync(
61
+ new URL(`../components/${file}`, import.meta.url),
62
+ "utf8"
63
+ );
64
+ const sw = src.slice(src.indexOf("<v-switch"), src.indexOf("/>", src.indexOf("<v-switch")));
65
+
66
+ assert.ok(sw.includes('class="app-switch"'), "missing .app-switch");
67
+ assert.ok(!/\binset\b/.test(sw), "inset fights the .app-switch dimensions");
68
+ });
69
+ }
package/utils/role.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether the Create-role dialog should show its (read-only) Site field.
3
+ *
4
+ * An admin role is org-wide, never site-scoped, so there is no site to show -
5
+ * the field could only ever render empty and permanently disabled, which is
6
+ * what QA reported on the admin app. Every other role type keeps whatever the
7
+ * consuming app asked for.
8
+ */
9
+ export const showSiteField = (siteState: boolean, type: string): boolean =>
10
+ siteState && type !== "admin";
@@ -1,157 +0,0 @@
1
- <template>
2
- <v-row no-gutters align="center" justify="center">
3
- <v-col cols="12" lg="10">
4
- <TableMain
5
- :title="'Schedule Tasks'"
6
- :items="items"
7
- :headers="headers"
8
- :loading="loading"
9
- :show-header="true"
10
- v-model:page="page"
11
- :pages="pages"
12
- :pageRange="pageRange"
13
- :no-data-text="'No Schedule Tasks found'"
14
- @row-click="onRowClick"
15
- >
16
- <template #item.task="{ item }">
17
- {{ item.task || "N/A" }}
18
- </template>
19
-
20
- <template #item.createdAt="{ value }">
21
- {{
22
- formatDate
23
- ? formatDate(value)
24
- : value
25
- ? new Date(value).toLocaleDateString()
26
- : ""
27
- }}
28
- </template>
29
-
30
- <template #item.area="{ value }">
31
- {{ value || "N/A" }}
32
- </template>
33
-
34
- <template #item.createdBy="{ item }">
35
- <!--
36
- THE 3.12:1 `HS` CHIP.
37
-
38
- This was a hand-rolled copy of `AvatarMain` with two problems: its
39
- fill was Vuetify's MATERIAL `blue`/`purple`/`grey` (not a theme
40
- token, so no `on-*` pair applied to it) and its `white--text` is
41
- Vuetify 2 syntax that does nothing in Vuetify 3 - leaving Vuetify to
42
- derive WHITE, which reads 3.12:1 on that blue in BOTH themes.
43
- `AvatarMain` draws the same circle with the same initials and now
44
- guarantees its label clears 4.5:1.
45
- -->
46
- <AvatarMain :name="item.createdBy" :size="32" class="mr-2" />
47
- {{ item.createdBy || "N/A" }}
48
- </template>
49
-
50
- <template #item.cleaners="{ value }">
51
- {{ value || "N/A" }}
52
- </template>
53
-
54
- <template #item.status="{ value }">
55
- <StatusChip :status="value || 'To-Do'" :label="value || 'To-Do'" />
56
- </template>
57
- <!-- Completion Date/Time -->
58
- <template #item.completionDate="{ value }">
59
- {{ value ? formatDate(value) : "N/A" }}
60
- </template>
61
- <!-- Action (Accept button) -->
62
- <template #item.action="{ item }">
63
- <v-btn
64
- variant="outlined"
65
- class="text-none screen-btn-ghost"
66
- @click.stop="acceptTask(item)"
67
- >
68
- Accept
69
- </v-btn>
70
- </template>
71
- </TableMain>
72
-
73
- <!-- Accept Confirmation Dialog -->
74
- <AcceptDialog
75
- :model-value="showAcceptDialog"
76
- title="Accept Scheduled Task"
77
- message="Are you sure you want to Accept this Scheduled Task?"
78
- @confirm="confirmAccept"
79
- @cancel="showAcceptDialog = false"
80
- />
81
-
82
- <!-- ScheduleTaskForm Dialog -->
83
- <ScheduleTaskForm v-model:show="showForm" />
84
- </v-col>
85
- </v-row>
86
- </template>
87
-
88
- <script lang="ts" setup>
89
- const props = defineProps({
90
- orgId: { type: String, required: true },
91
- site: { type: String, required: true },
92
- type: { type: String, required: true },
93
- });
94
- const loading = ref(false);
95
- const page = ref(1);
96
- const pages = ref(1);
97
- const pageRange = ref("1-3 of 3");
98
- const showForm = ref(false);
99
-
100
- const showAcceptDialog = ref(false);
101
- const selectedTask = ref(null);
102
-
103
- const items = ref([
104
- {
105
- _id: "1",
106
- task: "Clean Windows",
107
- createdAt: "2025-10-28T10:00:00Z",
108
- area: "",
109
- createdBy: "Demo Smith",
110
- cleaners: "N/A",
111
- status: "To-Do",
112
- completionDate: "2025-10-28T10:00:00Z",
113
- },
114
- {
115
- _id: "2",
116
- task: "Clean The 3",
117
- createdAt: "2025-10-28T10:00:00Z",
118
- area: "",
119
- createdBy: "Hygiene SP",
120
- cleaners: "N/A",
121
- status: "To-Do",
122
- completionDate: "2025-10-28T10:00:00Z",
123
- },
124
- ]);
125
-
126
- const headers = [
127
- { title: "Task", value: "task" },
128
- { title: "Date Created", value: "createdAt" },
129
- { title: "Area", value: "area" },
130
- { title: "Created By", value: "createdBy" },
131
- { title: "Cleaners", value: "cleaners" },
132
- { title: "Status", value: "status" },
133
- { title: "Completion Date/Time", value: "completionDate" },
134
- { title: "Action", value: "action" },
135
- ];
136
-
137
- const { formatDate } = useUtils();
138
-
139
- function acceptTask(item: any) {
140
- selectedTask.value = item;
141
- showAcceptDialog.value = true;
142
- }
143
- function confirmAccept() {
144
- // Handle accept logic here (e.g., update status, API call)
145
- showAcceptDialog.value = false;
146
- }
147
- function onRowClick(data: any) {
148
- const item = data?.item ?? data;
149
- const id = item?._id || item?.id;
150
- if (id) {
151
- const path = `/${props.orgId}/${props.site}/schedule-tasks-ticket/${id}`;
152
- navigateTo(path);
153
- } else {
154
- console.warn("Row clicked but no id found to navigate:", item);
155
- }
156
- }
157
- </script>