@7365admin1/layer-common 3.2.2-staging.92 → 3.2.2-staging.94

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.
@@ -0,0 +1,27 @@
1
+ ---
2
+ "@7365admin1/layer-common": patch
3
+ ---
4
+
5
+ Group the sidebar's sections and give the list screens the design's page
6
+ scaffold.
7
+
8
+ - `withSections()` walked each application's menu in the order it was given and
9
+ printed a heading whenever the section changed, so Property Management drew
10
+ SERVICE DESK, PROPERTY and SECURITY OPERATIONS **twice each**. Items are now
11
+ grouped under one heading per section, in the order the design lists them,
12
+ with the application's own order kept inside each section. No menu item is
13
+ added, hidden, renamed, re-routed or re-gated.
14
+ - `ServiceProviderMain`, `FeedbackMain`, `InvitationMain` and `WorkOrder/Main`
15
+ had no page title, put their primary action top-left, floated the search
16
+ outside the card, and stacked the refresh/pager on a bar above the tabs. They
17
+ now use `PageHeader` / `AppCard` / `CardToolbar` / `AppButton` / `AppField`:
18
+ a 22px title with the action on the right, then one in-card row with the tabs
19
+ on the left and search, count and pager on the right.
20
+ - `SwitchContext` and the rail's application name print in full instead of
21
+ truncating to "Seventh Cond…" / "Property Managem…".
22
+ - The security dashboard's `ACTIVE PATROL` / `STAFF STATUS` panel headings are
23
+ written the way the design writes them, matching their sibling panels.
24
+
25
+ Placement and wording only: the same buttons open the same dialogs under the
26
+ same permissions, the same tabs load the same lists, and the tables, columns,
27
+ filters and row menus are untouched.
@@ -108,6 +108,9 @@
108
108
  .app-toolbar {
109
109
  display: flex;
110
110
  align-items: center;
111
+ /* One row at desktop widths, as drawn. On a phone the tabs and the
112
+ search/pager group drop onto their own lines rather than overflow. */
113
+ flex-wrap: wrap;
111
114
  gap: 22px;
112
115
  padding: 0 var(--cellpad-x);
113
116
  border-bottom: 1px solid var(--border);
@@ -129,6 +132,14 @@
129
132
  gap: 8px;
130
133
  }
131
134
 
135
+ /* The list toolbar's own search box. `--search` alone stretches (it is written
136
+ for the filter row), which would push the pager off the end of the row. */
137
+ .app-toolbar__search.app-field--search {
138
+ flex: 0 0 auto;
139
+ width: 200px;
140
+ min-width: 0;
141
+ }
142
+
132
143
  .app-toolbar__count {
133
144
  font-size: 12.5px;
134
145
  font-weight: 600;
@@ -161,6 +172,9 @@
161
172
  color: var(--muted);
162
173
  white-space: nowrap;
163
174
  cursor: pointer;
175
+ /* A tab is a <button> on the screens that switch a list in place and a link
176
+ on the ones that route (Invitations), so the underline has to be ours. */
177
+ text-decoration: none;
164
178
  }
165
179
 
166
180
  .app-tab:hover {
@@ -361,6 +361,20 @@
361
361
  border-bottom: 1px solid var(--border);
362
362
  }
363
363
 
364
+ /**
365
+ * The callers' filter row (`#extension`). It was a `v-toolbar` extension, i.e.
366
+ * a fixed-height second bar; it is now a plain row under the toolbar with the
367
+ * same rule under it, so it can size to whatever the caller puts in it.
368
+ */
369
+ .table-card__extension {
370
+ border-bottom: 1px solid var(--border);
371
+ }
372
+
373
+ .table-card__loading {
374
+ position: absolute;
375
+ z-index: 1;
376
+ }
377
+
364
378
  .table-card__range {
365
379
  font-size: 12px;
366
380
  font-weight: 600;
@@ -888,7 +888,7 @@
888
888
  <v-col v-if="isWidgetVisible('activePatrol')" cols="12" lg="6">
889
889
  <div class="dashboard-card dashboard-panel figma-panel">
890
890
  <div class="figma-panel-header">
891
- <h2>ACTIVE PATROL</h2>
891
+ <h2>Active Patrol</h2>
892
892
  <button
893
893
  class="figma-panel-action"
894
894
  type="button"
@@ -968,7 +968,7 @@
968
968
  <v-col v-if="isWidgetVisible('staffStatus')" cols="12" lg="6">
969
969
  <div class="dashboard-card dashboard-panel figma-panel">
970
970
  <div class="figma-panel-header">
971
- <h2>STAFF STATUS</h2>
971
+ <h2>Staff Status</h2>
972
972
  <button
973
973
  class="figma-panel-action"
974
974
  type="button"
@@ -2,7 +2,7 @@
2
2
  <v-row no-gutters align="center" justify="center">
3
3
  <v-col cols="12" lg="12">
4
4
  <TableMain
5
- :title="`${siteName} Units`"
5
+ :title="`${siteName} Equipment Items`"
6
6
  :items="items"
7
7
  :headers="headers"
8
8
  :loading="loading"
@@ -2,7 +2,7 @@
2
2
  <v-row no-gutters align="center" justify="center">
3
3
  <v-col cols="12" lg="12">
4
4
  <TableMain
5
- :title="`${siteName} Units`"
5
+ :title="`${siteName} Equipment`"
6
6
  :items="items"
7
7
  :headers="headers"
8
8
  :loading="loading"
@@ -1,5 +1,15 @@
1
1
  <template>
2
- <v-row no-gutters>
2
+ <div>
3
+ <!-- The design's title row: the page's name at 22px and its primary
4
+ action on the right of that same line. Placement only - the same
5
+ button opens the same dialog under the same permission. -->
6
+ <PageHeader title="Feedbacks">
7
+ <template v-if="canCreateFeedback" #actions>
8
+ <AppButton @click="showCreateDialog = true">Create Feedback</AppButton>
9
+ </template>
10
+ </PageHeader>
11
+
12
+ <v-row no-gutters>
3
13
  <v-col cols="12" class="mb-2">
4
14
  <v-row no-gutters align="center" class="filter-bar">
5
15
  <v-text-field
@@ -50,47 +60,32 @@
50
60
  <v-date-picker v-model="endDate" width="320" :locale="locale" />
51
61
  </v-card>
52
62
  </v-dialog>
53
- <v-btn
54
- v-if="canCreateFeedback"
55
- text="create feedback"
56
- class="text-none text-capitalize ml-2 screen-btn-primary filter-bar__fit"
57
- variant="flat"
58
- @click="showCreateDialog = true"
59
- />
60
63
  </v-row>
61
64
  </v-col>
62
65
 
63
66
  <v-col cols="12">
64
- <v-card width="100%" variant="flat" class="table-card" :loading="loading">
65
- <v-toolbar
66
- density="compact"
67
- color="transparent"
68
- class="table-card__toolbar"
69
- >
70
- <template #prepend>
71
- <v-btn
72
- fab
73
- icon
74
- density="comfortable"
67
+ <AppCard class="table-card">
68
+ <!-- One row inside the card - refresh, the range and the pager - in
69
+ place of a `v-toolbar` that stacked them above the table. Same
70
+ three controls, same handlers. -->
71
+ <CardToolbar :count="pageRange">
72
+ <template #left>
73
+ <AppButton
74
+ variant="icon"
75
+ icon="mdi-refresh"
76
+ aria-label="Refresh"
75
77
  @click="(page = 1), refreshFeedbacks()"
76
- >
77
- <v-icon icon="mdi-refresh" />
78
- </v-btn>
78
+ />
79
79
  </template>
80
80
 
81
- <template #append>
82
- <v-row no-gutters justify="end" align="center">
83
- <span class="mr-2 table-card__range">
84
- {{ pageRange }}
85
- </span>
86
- <local-pagination
87
- v-model="page"
88
- :length="pages"
89
- @update:value="updatePage"
90
- />
91
- </v-row>
81
+ <template #right>
82
+ <local-pagination
83
+ v-model="page"
84
+ :length="pages"
85
+ @update:value="updatePage"
86
+ />
92
87
  </template>
93
- </v-toolbar>
88
+ </CardToolbar>
94
89
 
95
90
  <v-data-table
96
91
  :headers="headers"
@@ -100,6 +95,7 @@
100
95
  fixed-header
101
96
  hide-default-footer
102
97
  :hide-default-header="false"
98
+ :loading="loading"
103
99
  @click:row="tableRowClickHandler"
104
100
  style="max-height: calc(100vh - (200px))"
105
101
  >
@@ -173,9 +169,10 @@
173
169
  <span v-else class="mt-1 text-wrap"> Not Assigned </span>
174
170
  </template>
175
171
  </v-data-table>
176
- </v-card>
172
+ </AppCard>
177
173
  </v-col>
178
- </v-row>
174
+ </v-row>
175
+ </div>
179
176
 
180
177
  <!-- <FeedbackForm
181
178
  v-model="showCreateDialog"
@@ -1,69 +1,45 @@
1
1
  <template>
2
- <v-row no-gutters>
3
- <v-col cols="12" class="mb-2">
4
- <v-row no-gutters>
5
- <v-btn
6
- class="text-none mr-2 screen-btn-primary"
7
- variant="flat"
8
- @click="inviteMember()"
9
- v-if="props.inviteMember"
10
- >
11
- Invite member
12
- </v-btn>
13
- </v-row>
14
- </v-col>
2
+ <div>
3
+ <!-- The design's page scaffold: a title with the action on the right of
4
+ that line, then ONE row inside the card - the two tabs on the left,
5
+ refresh, the range and the pager on the right. The tabs route exactly
6
+ as they did; only where they sit has changed. -->
7
+ <PageHeader title="Invitations">
8
+ <template v-if="props.inviteMember" #actions>
9
+ <AppButton @click="inviteMember()">Invite member</AppButton>
10
+ </template>
11
+ </PageHeader>
12
+
13
+ <v-row no-gutters>
15
14
  <v-col cols="12">
16
- <v-card width="100%" variant="flat" class="table-card">
17
- <v-toolbar
18
- density="compact"
19
- color="transparent"
20
- class="table-card__toolbar"
21
- >
22
- <template #prepend>
23
- <v-btn
24
- fab
25
- icon
26
- density="comfortable"
27
- @click="getVerifications()"
15
+ <AppCard class="table-card">
16
+ <CardToolbar :count="pageRange">
17
+ <template #tabs>
18
+ <NuxtLink
19
+ v-for="tab in invitationTabs"
20
+ :key="tab.status"
21
+ class="app-tab"
22
+ :class="{ 'app-tab--active': props.status === tab.status }"
23
+ :to="{ name: props.route, params: { status: tab.status } }"
28
24
  >
29
- <v-icon>mdi-refresh</v-icon>
30
- </v-btn>
31
- </template>
32
-
33
- <template #append>
34
- <v-row no-gutters justify="end" align="center">
35
- <span class="mr-2 table-card__range">
36
- {{ pageRange }}
37
- </span>
38
- <local-pagination
39
- v-model="page"
40
- :length="pages"
41
- @update:value="getVerifications"
42
- />
43
- </v-row>
25
+ {{ tab.label }}
26
+ </NuxtLink>
44
27
  </template>
45
28
 
46
- <template #extension>
47
- <v-tabs>
48
- <v-tab
49
- :to="{
50
- name: props.route,
51
- params: { status: 'pending' },
52
- }"
53
- >
54
- Pending
55
- </v-tab>
56
- <v-tab
57
- :to="{
58
- name: props.route,
59
- params: { status: 'expired' },
60
- }"
61
- >
62
- Expired
63
- </v-tab>
64
- </v-tabs>
29
+ <template #right>
30
+ <AppButton
31
+ variant="icon"
32
+ icon="mdi-refresh"
33
+ aria-label="Refresh"
34
+ @click="getVerifications()"
35
+ />
36
+ <local-pagination
37
+ v-model="page"
38
+ :length="pages"
39
+ @update:value="getVerifications"
40
+ />
65
41
  </template>
66
- </v-toolbar>
42
+ </CardToolbar>
67
43
 
68
44
  <v-data-table
69
45
  :headers="headers"
@@ -105,7 +81,7 @@
105
81
  {{ formatStatus(value) }}
106
82
  </template>
107
83
  </v-data-table>
108
- </v-card>
84
+ </AppCard>
109
85
  </v-col>
110
86
 
111
87
  <ConfirmDialog
@@ -155,10 +131,17 @@
155
131
  :app="props.app"
156
132
  />
157
133
  </v-dialog>
158
- </v-row>
134
+ </v-row>
135
+ </div>
159
136
  </template>
160
137
 
161
138
  <script setup lang="ts">
139
+ /** The same two tabs, same labels, same routes. */
140
+ const invitationTabs = [
141
+ { status: "pending", label: "Pending" },
142
+ { status: "expired", label: "Expired" },
143
+ ];
144
+
162
145
  const props = defineProps({
163
146
  app: {
164
147
  type: String,
@@ -283,9 +283,10 @@ const handleClick = (item: any) => {
283
283
  font-weight: var(--fw-card-title);
284
284
  line-height: 1.2;
285
285
  color: var(--text);
286
- overflow: hidden;
287
- white-space: nowrap;
288
- text-overflow: ellipsis;
286
+ /* The design prints the application's name in full ("Property Management").
287
+ Truncating it to "Property Managem…" in a 252px rail loses the word that
288
+ tells the user which product they are in, so it wraps instead. */
289
+ overflow-wrap: anywhere;
289
290
  text-transform: capitalize;
290
291
  }
291
292
 
@@ -1,108 +1,91 @@
1
1
  <template>
2
- <v-row no-gutters>
3
- <v-col cols="12" class="mb-3">
4
- <v-row no-gutters align="center" justify="space-between">
5
- <div class="d-flex flex-wrap align-center">
6
- <v-btn
7
- v-if="showAddButton && canAddServiceProvider"
8
- class="text-none mr-2 screen-btn-primary"
9
- variant="flat"
10
- @click="(createDialog = true), setServiceProvider({ mode: 'add' })"
11
- >
12
- Add
13
- </v-btn>
14
- <v-btn
15
- v-if="canInviteServiceProvider"
16
- class="text-none mr-2 screen-btn-primary"
17
- variant="flat"
18
- @click="
19
- (createInviteDialog = true),
20
- setServiceProvider({ mode: 'invite' })
21
- "
22
- >
23
- Invite
24
- </v-btn>
25
- <v-btn
26
- v-if="showBillingButton"
27
- class="text-none mr-2 screen-btn-primary"
28
- variant="flat"
29
- @click="
30
- useRouter().push({
31
- name: 'org-site-service-provider-mgmt-billing',
32
- })
33
- "
34
- >
35
- Billing
36
- </v-btn>
37
- </div>
38
-
39
- <v-text-field
40
- v-model="searchText"
41
- placeholder="Search"
42
- variant="outlined"
43
- density="comfortable"
44
- clearable
45
- hide-details
46
- prepend-inner-icon="mdi-magnify"
47
- class="ml-2"
48
- style="max-width: 280px"
49
- />
50
- </v-row>
51
- </v-col>
52
-
53
- <v-col cols="12">
54
- <v-card
55
- width="100%"
56
- variant="flat"
57
- class="table-card"
58
- :loading="loading"
59
- >
60
- <div class="sp-table-header">
61
- <!-- No fixed colour: `grey-lighten-4` is a light-mode grey in both
62
- themes, which is what made this strip a white slab in dark
63
- mode. `transparent` lets the themed .sp-table-header show. -->
64
- <v-toolbar
65
- density="compact"
66
- color="transparent"
67
- flat
68
- class="sp-toolbar-top px-2"
69
- >
70
- <v-btn
71
- icon
72
- variant="text"
73
- density="comfortable"
74
- @click="loadList()"
75
- >
76
- <v-icon>mdi-refresh</v-icon>
77
- </v-btn>
78
-
79
- <v-spacer />
80
-
81
- <template v-if="canViewServiceProviders">
82
- <span class="mr-2 text-caption text-medium-emphasis">{{
83
- pageRange
84
- }}</span>
85
- <local-pagination
86
- v-model="page"
87
- :length="pages"
88
- @update:value="loadList()"
89
- />
90
- </template>
91
- </v-toolbar>
92
-
93
- <v-tabs
94
- v-model="listTab"
95
- color="primary"
96
- density="comfortable"
97
- class="sp-tabs screen-tabs"
98
- align-tabs="start"
2
+ <div>
3
+ <!--
4
+ THE DESIGN'S PAGE SCAFFOLD (same shape as Members).
5
+
6
+ The screen had no title at all, its primary actions sat top-LEFT, the
7
+ search box floated outside the card, and the refresh + pager sat on one
8
+ row with the tabs stacked on a second row below. The design draws a 22px
9
+ title with the actions on the right of that line, then ONE row inside the
10
+ card: tabs left, search / refresh / count / pager right.
11
+
12
+ Placement only. The same buttons open the same dialogs under the same
13
+ permission conditions, the same three tabs load the same three lists, the
14
+ same search feeds the same debounced `loadList()`, and the table, its
15
+ columns and its row menu are untouched.
16
+ -->
17
+ <PageHeader title="Service Providers">
18
+ <template #actions>
19
+ <AppButton
20
+ v-if="showBillingButton"
21
+ variant="ghost"
22
+ @click="
23
+ useRouter().push({
24
+ name: 'org-site-service-provider-mgmt-billing',
25
+ })
26
+ "
27
+ >
28
+ Billing
29
+ </AppButton>
30
+ <AppButton
31
+ v-if="showAddButton && canAddServiceProvider"
32
+ variant="ghost"
33
+ @click="(createDialog = true), setServiceProvider({ mode: 'add' })"
34
+ >
35
+ Add
36
+ </AppButton>
37
+ <AppButton
38
+ v-if="canInviteServiceProvider"
39
+ @click="
40
+ (createInviteDialog = true), setServiceProvider({ mode: 'invite' })
41
+ "
42
+ >
43
+ Invite
44
+ </AppButton>
45
+ </template>
46
+ </PageHeader>
47
+
48
+ <AppCard class="table-card">
49
+ <CardToolbar :count="canViewServiceProviders ? pageRange : ''">
50
+ <template #tabs>
51
+ <button
52
+ v-for="tab in listTabs"
53
+ :key="tab.value"
54
+ type="button"
55
+ role="tab"
56
+ class="app-tab"
57
+ :class="{ 'app-tab--active': listTab === tab.value }"
58
+ :aria-selected="listTab === tab.value"
59
+ @click="listTab = tab.value"
99
60
  >
100
- <v-tab value="active" class="text-none">Service Providers</v-tab>
101
- <v-tab value="pending" class="text-none">Pending</v-tab>
102
- <v-tab value="inactive" class="text-none">In-Active</v-tab>
103
- </v-tabs>
104
- </div>
105
-
61
+ {{ tab.label }}
62
+ </button>
63
+ </template>
64
+
65
+ <template #right>
66
+ <AppField
67
+ v-model="searchText"
68
+ search
69
+ compact
70
+ placeholder="Search"
71
+ klass="app-toolbar__search"
72
+ />
73
+ <AppButton
74
+ variant="icon"
75
+ icon="mdi-refresh"
76
+ aria-label="Refresh"
77
+ @click="loadList()"
78
+ />
79
+ <local-pagination
80
+ v-if="canViewServiceProviders"
81
+ v-model="page"
82
+ :length="pages"
83
+ @update:value="loadList()"
84
+ />
85
+ </template>
86
+ </CardToolbar>
87
+
88
+ <div class="app-table-scroll">
106
89
  <v-data-table
107
90
  v-if="canViewServiceProviders"
108
91
  :headers="headers"
@@ -111,6 +94,7 @@
111
94
  :items-per-page="pageSize"
112
95
  fixed-header
113
96
  hide-default-footer
97
+ :loading="loading"
114
98
  class="sp-table"
115
99
  style="max-height: calc(100vh - 280px)"
116
100
  >
@@ -222,12 +206,16 @@
222
206
  </v-menu>
223
207
  </template>
224
208
  </v-data-table>
225
- <v-card-text v-else class="text-body-2 text-medium-emphasis">
226
- You do not have permission to view service providers.
227
- </v-card-text>
228
- </v-card>
229
- </v-col>
230
- </v-row>
209
+ </div>
210
+
211
+ <div
212
+ v-if="!canViewServiceProviders"
213
+ class="pa-4 text-body-2 text-medium-emphasis"
214
+ >
215
+ You do not have permission to view service providers.
216
+ </div>
217
+ </AppCard>
218
+ </div>
231
219
 
232
220
  <v-dialog v-model="viewDialog" max-width="514">
233
221
  <v-card rounded="lg" class="sp-members-card">
@@ -791,6 +779,12 @@ const { requiredRule, emailRule, debounce } = useUtils();
791
779
  const { getUserByEmail } = useUser();
792
780
 
793
781
  const listTab = ref<ListTab>("active");
782
+ /** The same three tabs, in the same order, with the same labels. */
783
+ const listTabs: Array<{ value: ListTab; label: string }> = [
784
+ { value: "active", label: "Service Providers" },
785
+ { value: "pending", label: "Pending" },
786
+ { value: "inactive", label: "In-Active" },
787
+ ];
794
788
  const page = ref(1);
795
789
  const pages = ref(0);
796
790
  const pageRange = ref("-- - -- of --");
@@ -1489,32 +1483,8 @@ async function submitServiceProviderInvite() {
1489
1483
  </script>
1490
1484
 
1491
1485
  <style scoped>
1492
- .sp-toolbar-top {
1493
- display: flex;
1494
- align-items: center;
1495
- flex-wrap: nowrap;
1496
- gap: 4px;
1497
- }
1498
-
1499
- .sp-table-header {
1500
- /* Was a hardcoded #f5f5f5 with a black border: in dark mode the tab strip
1501
- became a white slab and the tab labels and the page range disappeared
1502
- into it. These take the colour of whichever theme they are in. */
1503
- background: rgb(var(--v-theme-surface-light, var(--v-theme-surface)));
1504
- border-bottom: 1px solid
1505
- rgba(var(--v-border-color), var(--v-border-opacity));
1506
- }
1507
-
1508
- .sp-tabs {
1509
- width: 100%;
1510
- min-height: 40px;
1511
- }
1512
-
1513
- .sp-tabs :deep(.v-tab) {
1514
- min-width: 120px;
1515
- justify-content: flex-start;
1516
- text-transform: none;
1517
- }
1486
+ /* The tab strip's own theming went with the strip: the tabs are the card's
1487
+ first row now and take `.app-tab` from the shared primitives. */
1518
1488
 
1519
1489
  .sp-col-company {
1520
1490
  max-width: 220px;
@@ -15,7 +15,10 @@
15
15
  :to="props.route"
16
16
  class="text-subtitle-2 font-weight-bold"
17
17
  >
18
- <v-list-item-title class="text-truncate">
18
+ <!-- The design prints the organisation and site names in full.
19
+ Vuetify's list-item title truncates by default, which cut them
20
+ to "Property Managem…" / "Seventh Cond…" in the 252px rail. -->
21
+ <v-list-item-title class="switch-context__name">
19
22
  {{ name || `Select ${prop.title}` }}
20
23
  </v-list-item-title>
21
24
  </v-list-item>
@@ -124,3 +127,14 @@ const selectItem = (value: string) => {
124
127
  emit("select", value);
125
128
  };
126
129
  </script>
130
+
131
+ <style scoped>
132
+ /* Wrap onto a second line instead of truncating - the design shows the full
133
+ organisation and site names. */
134
+ .switch-context__name {
135
+ white-space: normal;
136
+ overflow: visible;
137
+ overflow-wrap: anywhere;
138
+ line-height: 1.25;
139
+ }
140
+ </style>
@@ -15,6 +15,24 @@
15
15
  -->
16
16
  <template>
17
17
  <v-row no-gutters>
18
+ <!--
19
+ THE DESIGN'S PAGE TITLE (Phase 9).
20
+
21
+ The design opens every screen with a 22px/800 heading. This shared table
22
+ card is mounted by 17 module screens, and none of them had one - four of
23
+ them (Units, Areas, Equipment Item, Equipment Management) were already
24
+ PASSING `:title` to this component, which had no `title` prop, so the
25
+ string was landing on the root element as a plain HTML `title` attribute:
26
+ a browser tooltip nobody asked for. It now draws the heading it was
27
+ always meant to draw.
28
+
29
+ Placement only. No caller changes, no slot moves, no behaviour: a screen
30
+ that passes no title renders exactly what it rendered before.
31
+ -->
32
+ <v-col cols="12" v-if="title">
33
+ <PageHeader :title="title" />
34
+ </v-col>
35
+
18
36
  <!-- Top Actions -->
19
37
  <v-col cols="12" class="mb-2" v-if="canCreate || $slots.actions">
20
38
  <v-row no-gutters>
@@ -62,43 +80,56 @@
62
80
 
63
81
  <!-- Table Card -->
64
82
  <v-col cols="12">
65
- <v-card
66
- width="100%"
67
- variant="flat"
68
- class="table-card"
69
- :loading="loading"
70
- >
83
+ <!--
84
+ THE DESIGN'S CARD AND ITS IN-CARD TOP ROW (Phase 9).
85
+
86
+ Was a `v-card` + `v-toolbar`. `v-card` brings its own radius, elevation
87
+ and surface at a specificity a class has to fight, and `v-toolbar` puts
88
+ its `extension` on a SECOND stacked row. `AppCard` + `CardToolbar` are
89
+ the primitives the design is drawn with, already proven on
90
+ `ServiceProviderMain` / `MemberMain`.
91
+
92
+ Placement only. The refresh button, the `prepend-additional` slot, the
93
+ page range, the pager, the `extension` slot and the `footer` slot are
94
+ the same controls, emitting the same events, in the same order. The
95
+ `loading` bar `v-card` used to draw is drawn explicitly so a loading
96
+ screen still says so.
97
+ -->
98
+ <AppCard class="table-card">
99
+ <v-progress-linear
100
+ v-if="loading"
101
+ indeterminate
102
+ height="2"
103
+ class="table-card__loading"
104
+ />
105
+
71
106
  <!-- Toolbar -->
72
- <v-toolbar
73
- density="compact"
74
- color="transparent"
75
- class="table-card__toolbar"
76
- :extension-height="extensionHeight"
77
- >
78
- <template #prepend>
107
+ <CardToolbar :count="pageRange">
108
+ <template #left>
79
109
  <v-btn fab icon density="comfortable" variant="text" @click="emits('refresh')">
80
110
  <v-icon>mdi-refresh</v-icon>
81
111
  </v-btn>
82
112
  <slot name="prepend-additional" />
83
113
  </template>
84
114
 
85
- <template #append>
86
- <v-row no-gutters justify="end" align="center">
87
- <span class="mr-2 table-card__range">
88
- {{ pageRange }}
89
- </span>
90
- <local-pagination
91
- v-model="internalPage"
92
- :length="pages"
93
- @update:value="emits('update:page', internalPage)"
94
- />
95
- </v-row>
115
+ <template #right>
116
+ <local-pagination
117
+ v-model="internalPage"
118
+ :length="pages"
119
+ @update:value="emits('update:page', internalPage)"
120
+ />
96
121
  </template>
122
+ </CardToolbar>
97
123
 
98
- <template v-if="$slots.extension" #extension>
99
- <slot name="extension" />
100
- </template>
101
- </v-toolbar>
124
+ <!--
125
+ The callers' `extension` is a FILTER row (selects, date ranges), not
126
+ tabs - it is full-width by construction, so it keeps its own line
127
+ under the toolbar rather than being folded into it, which would push
128
+ the pager off the end of the row.
129
+ -->
130
+ <div v-if="$slots.extension" class="table-card__extension">
131
+ <slot name="extension" />
132
+ </div>
102
133
 
103
134
  <!-- Data Table -->
104
135
  <v-data-table
@@ -129,13 +160,18 @@
129
160
  </template>
130
161
  </v-data-table>
131
162
  <slot name="footer" />
132
- </v-card>
163
+ </AppCard>
133
164
  </v-col>
134
165
  </v-row>
135
166
  </template>
136
167
 
137
168
  <script lang="ts" setup>
138
169
  const props = defineProps({
170
+ // The design's page heading. Empty = no heading, i.e. exactly today's markup.
171
+ title: {
172
+ type: String,
173
+ default: "",
174
+ },
139
175
  headers: {
140
176
  type: Array as PropType<Array<Record<string, any>>>,
141
177
  required: true,
@@ -1,58 +1,47 @@
1
1
  <template>
2
- <v-row no-gutters>
3
- <v-col cols="12" class="mb-2">
4
- <v-row no-gutters align="center" justify="space-between">
5
- <v-btn
6
- class="text-none text-capitalize"
7
- rounded="pill"
8
- variant="tonal"
9
- size="large"
10
- @click="(showCreateDialog = true), (isEditMode = false)"
11
- v-if="canCreateWorkOrder"
12
- >
2
+ <div>
3
+ <!--
4
+ The design's page scaffold. The screen had no heading, its Create button
5
+ sat top-left and the search floated top-right outside the card, with
6
+ refresh and the pager stacked on their own bar above the table. Same
7
+ button, same dialog, same permission, same debounced search, same pager.
8
+ -->
9
+ <PageHeader title="Work Orders">
10
+ <template v-if="canCreateWorkOrder" #actions>
11
+ <AppButton @click="(showCreateDialog = true), (isEditMode = false)">
13
12
  Create Work Order
14
- </v-btn>
15
-
16
- <v-text-field
17
- v-model="searchText"
18
- placeholder="Search work order..."
19
- variant="outlined"
20
- density="comfortable"
21
- clearable
22
- hide-details
23
- class="ml-2"
24
- style="max-width: 250px"
25
- />
26
- </v-row>
27
- </v-col>
13
+ </AppButton>
14
+ </template>
15
+ </PageHeader>
16
+
17
+ <v-row no-gutters>
28
18
  <v-col cols="12">
29
- <v-card
30
- width="100%"
31
- variant="outlined"
32
- border="thin"
33
- rounded="lg"
34
- :loading="loading"
35
- >
36
- <v-toolbar density="compact" color="grey-lighten-4">
37
- <template #prepend>
38
- <v-btn fab icon density="comfortable" @click="updatePage(1)">
39
- <v-icon>mdi-refresh</v-icon>
40
- </v-btn>
19
+ <AppCard class="table-card">
20
+ <CardToolbar :count="pageRange">
21
+ <template #left>
22
+ <AppButton
23
+ variant="icon"
24
+ icon="mdi-refresh"
25
+ aria-label="Refresh"
26
+ @click="updatePage(1)"
27
+ />
41
28
  </template>
42
29
 
43
- <template #append>
44
- <v-row no-gutters justify="end" align="center">
45
- <span class="mr-2 text-caption text-fontgray">
46
- {{ pageRange }}
47
- </span>
48
- <local-pagination
49
- v-model="page"
50
- :length="pages"
51
- @update:value="updatePage"
52
- />
53
- </v-row>
30
+ <template #right>
31
+ <AppField
32
+ v-model="searchText"
33
+ search
34
+ compact
35
+ placeholder="Search work order..."
36
+ klass="app-toolbar__search"
37
+ />
38
+ <local-pagination
39
+ v-model="page"
40
+ :length="pages"
41
+ @update:value="updatePage"
42
+ />
54
43
  </template>
55
- </v-toolbar>
44
+ </CardToolbar>
56
45
 
57
46
  <v-data-table
58
47
  :headers="headers"
@@ -62,6 +51,7 @@
62
51
  fixed-header
63
52
  hide-default-footer
64
53
  :hide-default-header="false"
54
+ :loading="loading"
65
55
  @click:row="tableRowClickHandler"
66
56
  style="max-height: calc(100vh - (200px))"
67
57
  >
@@ -69,9 +59,10 @@
69
59
  {{ formatDateDDMMYYYYLocal(item.createdAt) }}
70
60
  </template>
71
61
  </v-data-table>
72
- </v-card>
62
+ </AppCard>
73
63
  </v-col>
74
- </v-row>
64
+ </v-row>
65
+ </div>
75
66
 
76
67
  <WorkOrderCreate
77
68
  v-model="showCreateDialog"
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.92",
5
+ "version": "3.2.2-staging.94",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -28,16 +28,17 @@ const SECURITY = [
28
28
  ].map((title) => ({ title }));
29
29
 
30
30
  /**
31
- * THE RULE THAT MATTERS. A label is decoration; the menu is the product. If
32
- * this ever fails, someone has taught the sidebar to reorder itself.
31
+ * THE RULE THAT MATTERS. Grouping may reorder the rail; it may never change
32
+ * the menu. Same objects, same count - nothing added, hidden or duplicated.
33
33
  */
34
- test("the menu comes back in the order it went in, item for item", () => {
34
+ test("every menu item comes back exactly once, and nothing else does", () => {
35
35
  const out = withSections(SECURITY);
36
36
 
37
37
  assert.equal(out.length, SECURITY.length);
38
- out.forEach((entry, i) => {
39
- assert.equal(entry.item, SECURITY[i], `position ${i}`);
40
- });
38
+ assert.deepEqual(
39
+ new Set(out.map((e) => e.item)),
40
+ new Set(SECURITY)
41
+ );
41
42
  });
42
43
 
43
44
  test("Security is labelled the way the design draws it", () => {
@@ -102,10 +103,10 @@ test("an unmapped FIRST item prints no heading rather than a blank one", () => {
102
103
 
103
104
  /**
104
105
  * The applications do not agree on order: Landscape lists Feedbacks after its
105
- * equipment modules. The heading is printed again rather than left off, so no
106
- * item ever sits under a heading that is not its own.
106
+ * equipment modules. Those two Feedbacks/Work Orders items are collected under
107
+ * ONE SERVICE DESK heading instead of printing the heading twice.
107
108
  */
108
- test("a section that comes back later is labelled again, not orphaned", () => {
109
+ test("a section that comes back later is grouped, not labelled twice", () => {
109
110
  const out = withSections([
110
111
  { title: "Feedbacks" },
111
112
  { title: "Equipment Items" },
@@ -113,8 +114,66 @@ test("a section that comes back later is labelled again, not orphaned", () => {
113
114
  ]);
114
115
 
115
116
  assert.deepEqual(
116
- out.map((e) => e.section),
117
- ["SERVICE DESK", "EQUIPMENT", "SERVICE DESK"]
117
+ out.map((e) => `${e.section}|${e.item.title}`),
118
+ [
119
+ "SERVICE DESK|Feedbacks",
120
+ "|Work Orders",
121
+ "EQUIPMENT|Equipment Items",
122
+ ]
123
+ );
124
+ });
125
+
126
+ /**
127
+ * Property Management is the screen that showed the defect: its own menu order
128
+ * printed SERVICE DESK, PROPERTY and SECURITY OPERATIONS twice each.
129
+ */
130
+ test("Property Management prints each heading exactly once", () => {
131
+ const PM = [
132
+ "Dashboard",
133
+ "Service Providers",
134
+ "Feedbacks",
135
+ "Work Orders",
136
+ "Facility Mgmt",
137
+ "Pass & Key Mgmt",
138
+ "HID Face Reader",
139
+ "Online Forms",
140
+ "NFC Patrol",
141
+ "Bulletin Board",
142
+ "Bulletin Videos",
143
+ "Visitor Mgmt",
144
+ "Document Mgmt",
145
+ "Access Mgmt",
146
+ ].map((title) => ({ title }));
147
+
148
+ const out = withSections(PM);
149
+ const headings = out.map((e) => e.section).filter(Boolean);
150
+
151
+ assert.deepEqual(headings, [
152
+ "OVERVIEW",
153
+ "SERVICE DESK",
154
+ "PROPERTY",
155
+ "SECURITY OPERATIONS",
156
+ "COMMUNITY",
157
+ ]);
158
+ assert.equal(new Set(headings).size, headings.length);
159
+ assert.deepEqual(
160
+ out.map((e) => e.item.title),
161
+ [
162
+ "Dashboard",
163
+ "Service Providers",
164
+ "Feedbacks",
165
+ "Work Orders",
166
+ "Online Forms",
167
+ "Facility Mgmt",
168
+ "Pass & Key Mgmt",
169
+ "Visitor Mgmt",
170
+ "Document Mgmt",
171
+ "Access Mgmt",
172
+ "HID Face Reader",
173
+ "NFC Patrol",
174
+ "Bulletin Board",
175
+ "Bulletin Videos",
176
+ ]
118
177
  );
119
178
  });
120
179
 
@@ -1,29 +1,33 @@
1
1
  /**
2
- * THE GROUPED SIDEBAR - section labels, without touching the menu.
2
+ * THE GROUPED SIDEBAR - one heading per section, each printed once.
3
3
  *
4
4
  * The design draws the sidebar in labelled sections (OVERVIEW, SERVICE DESK,
5
5
  * PROPERTY, SECURITY OPERATIONS, COMMUNITY, WORKFORCE, ADMINISTRATION). Each
6
6
  * application builds its own menu array in its `layouts/default.vue`, gated
7
- * item by item on permissions, and that array is the product's navigation.
7
+ * item by item on permissions.
8
8
  *
9
- * So this file adds a LABEL and nothing else:
9
+ * The applications do not agree on the order they list their modules in
10
+ * (Property Management interleaves five sections; Landscape puts Feedbacks
11
+ * after Equipment). Walking that order and printing a heading whenever the
12
+ * section changed - what this file used to do - made Property Management draw
13
+ * SERVICE DESK, PROPERTY and SECURITY OPERATIONS TWICE each. It reads as a
14
+ * bug, so the items are now GROUPED:
10
15
  *
11
- * - the order of the menu is the order the application gave us. Nothing is
12
- * moved, sorted, merged, hidden or renamed. A label is inserted when the
13
- * section CHANGES as we walk that existing order, which means the label is
14
- * always true of the items under it.
15
- * - a title this map has never heard of inherits the section above it, so a
16
- * new menu item added by any of the eleven applications appears exactly
17
- * where its author put it, under the heading it follows, rather than
18
- * disappearing or forcing a stray heading.
19
- * - the first item always gets its section printed, even if that section is
20
- * "" (nothing) - a leading blank label is simply not rendered.
16
+ * - every item is collected under its section, and each heading is printed
17
+ * exactly once, in the order the design lists the sections (which is the
18
+ * order they are declared in the map below).
19
+ * - inside a section the application's own order is preserved, so items that
20
+ * belong together stay in the sequence their author chose.
21
+ * - a title this map has never heard of inherits the section of the item
22
+ * above it, so a new menu item added by any of the eleven applications is
23
+ * grouped with its neighbours rather than disappearing or forcing a stray
24
+ * heading.
25
+ * - an item with no section at all (an unmapped FIRST item) leads the rail
26
+ * with no heading, exactly as before.
21
27
  *
22
- * A section may therefore appear twice in one sidebar. That is deliberate: the
23
- * applications do not all list their modules in the same order (Landscape puts
24
- * Feedbacks after Equipment; Property Management interleaves five sections),
25
- * and printing the heading again is honest, where suppressing it would leave a
26
- * run of items sitting under a heading that is not theirs.
28
+ * This changes what ORDER the items are drawn in and nothing else. No item is
29
+ * added, hidden, renamed, re-routed or re-gated - the array that comes out
30
+ * holds the same objects as the array that went in.
27
31
  */
28
32
 
29
33
  export const NAV_SECTIONS: Record<string, string> = {
@@ -110,19 +114,38 @@ export const NAV_SECTIONS: Record<string, string> = {
110
114
 
111
115
  export type TSectionedNavItem<T> = { item: T; section: string };
112
116
 
117
+ /** The design's section order - the order they are declared above. */
118
+ export const SECTION_ORDER: string[] = [
119
+ ...new Set(Object.values(NAV_SECTIONS)),
120
+ ];
121
+
113
122
  /**
114
- * Walks the menu IN THE ORDER GIVEN and tags each item with the section label
115
- * to print above it, or "" for no label. Same length, same order, same objects.
123
+ * Groups the menu by section and tags each item with the heading to print
124
+ * above it, or "" when the heading is already printed. Same length, same
125
+ * objects; only the order changes.
116
126
  */
117
127
  export function withSections<T extends { title?: string }>(
118
128
  items: T[]
119
129
  ): Array<TSectionedNavItem<T>> {
130
+ // An unmapped title inherits the section of the item above it.
120
131
  let current = "";
132
+ const resolved = items.map((item, index) => {
133
+ current = NAV_SECTIONS[String(item?.title ?? "")] ?? current;
134
+ return { item, section: current, index };
135
+ });
136
+
137
+ // Sectionless items (an unmapped first item) rank -1 and lead the rail.
138
+ // Inside a section the application's own order is kept.
139
+ const grouped = [...resolved].sort(
140
+ (a, b) =>
141
+ SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section) ||
142
+ a.index - b.index
143
+ );
121
144
 
122
- return items.map((item, index) => {
123
- const section = NAV_SECTIONS[String(item?.title ?? "")] ?? current;
124
- const changed = index === 0 || section !== current;
125
- current = section;
126
- return { item, section: changed ? section : "" };
145
+ let printed = "";
146
+ return grouped.map(({ item, section }) => {
147
+ const label = section === printed ? "" : section;
148
+ printed = section;
149
+ return { item, section: label };
127
150
  });
128
151
  }