@7365admin1/layer-common 3.2.1 → 3.2.2-staging.100

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.
Files changed (123) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/notification-preferences.md +17 -0
  7. package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +27 -0
  8. package/.changeset/service-provider-invitation-actions.md +28 -0
  9. package/assets/css/primitives.css +680 -0
  10. package/assets/css/screens.css +541 -0
  11. package/assets/css/shell.css +258 -0
  12. package/assets/css/tokens.css +220 -0
  13. package/components/AccessCardQrTagging.vue +1 -1
  14. package/components/AccessManagement.vue +53 -72
  15. package/components/AppButton.vue +52 -0
  16. package/components/AppCard.vue +20 -0
  17. package/components/AppDateField.vue +35 -0
  18. package/components/AppField.vue +46 -0
  19. package/components/AppKpiTile.vue +65 -0
  20. package/components/AppSegmented.vue +38 -0
  21. package/components/AppSelect.vue +203 -0
  22. package/components/AreaChecklistHistoryMain.vue +6 -2
  23. package/components/AreaMain.vue +9 -17
  24. package/components/AttendanceMain.vue +4 -3
  25. package/components/Avatar/Main.vue +54 -2
  26. package/components/BillingMain.vue +26 -30
  27. package/components/BuildingManagement/buildings.vue +27 -38
  28. package/components/BuildingManagement/units.vue +26 -42
  29. package/components/BulletinBoardManagement.vue +23 -10
  30. package/components/CameraWall.vue +1034 -0
  31. package/components/CameraWallTile.vue +818 -0
  32. package/components/CardToolbar.vue +37 -0
  33. package/components/CleaningScheduleMain.vue +29 -46
  34. package/components/ClientMain.vue +90 -78
  35. package/components/DashboardEmptyState.vue +11 -1
  36. package/components/DashboardMain.vue +349 -275
  37. package/components/DashboardPanel.vue +1 -1
  38. package/components/DocumentManagement.vue +129 -93
  39. package/components/EntryPassInformation.vue +4 -4
  40. package/components/EntryPassMain.vue +49 -27
  41. package/components/EquipmentItemMain.vue +4 -6
  42. package/components/EquipmentManagementMain.vue +9 -7
  43. package/components/FeedbackMain.vue +42 -56
  44. package/components/FormDialog.vue +60 -21
  45. package/components/HidAccessLogDashboard.vue +119 -44
  46. package/components/HidCameraCaptureDialog.vue +199 -0
  47. package/components/HidIntercomManagement.vue +825 -202
  48. package/components/HidQrCodeConfiguration.vue +927 -93
  49. package/components/HidServiceSettingsPanel.vue +20 -1
  50. package/components/HidUserEnrollment.vue +454 -106
  51. package/components/InvitationMain.vue +46 -61
  52. package/components/Layout/Header.vue +289 -37
  53. package/components/Layout/NavigationDrawer.vue +340 -30
  54. package/components/ManageChecklistMain.vue +17 -17
  55. package/components/MemberMain.vue +79 -60
  56. package/components/MyAttendanceMain.vue +3 -5
  57. package/components/NavigationItem.vue +9 -0
  58. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  59. package/components/NotificationSettings.vue +361 -0
  60. package/components/OnlineFormConfigurationForm.vue +620 -224
  61. package/components/OnlineFormFill.vue +36 -8
  62. package/components/OnlineFormsConfiguration.vue +7 -3
  63. package/components/PageHeader.vue +43 -0
  64. package/components/PassInformation.vue +104 -45
  65. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  66. package/components/QrTemplate/PrintDialog.vue +209 -196
  67. package/components/RolePermissionMain.vue +184 -91
  68. package/components/ScanVisitorQRCode.vue +35 -7
  69. package/components/ScheduleAreaMain.vue +15 -49
  70. package/components/ScheduleTaskMain.vue +23 -26
  71. package/components/ScheduleTastTicketMain.vue +18 -43
  72. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  73. package/components/ServiceProviderMain.vue +346 -157
  74. package/components/SiteSettings.vue +25 -10
  75. package/components/SpecificAttr.vue +10 -2
  76. package/components/StatusChip.vue +93 -0
  77. package/components/SwitchContext.vue +33 -1
  78. package/components/TableHygiene.vue +29 -46
  79. package/components/TableList.vue +1 -1
  80. package/components/TableListSecondary.vue +1 -1
  81. package/components/TableMain.vue +161 -77
  82. package/components/TableV3.vue +61 -66
  83. package/components/UnitMain.vue +9 -17
  84. package/components/VehicleManagement.vue +20 -9
  85. package/components/VisitorForm.vue +294 -78
  86. package/components/VisitorManagement.vue +221 -71
  87. package/components/VisitorSocketPopUp.vue +56 -2
  88. package/components/WorkOrder/Main.vue +42 -51
  89. package/composables/useComment.ts +3 -3
  90. package/composables/useHidAmico.ts +151 -0
  91. package/composables/useHidNavigation.ts +0 -7
  92. package/composables/useLocalAuth.ts +9 -36
  93. package/composables/useNotificationPreference.ts +87 -0
  94. package/composables/useOnlineFormPages.ts +2 -0
  95. package/composables/useOrg.ts +1 -1
  96. package/composables/useRole.ts +22 -0
  97. package/composables/useServiceProviderInvitation.ts +145 -0
  98. package/composables/useSipWebPhone.ts +336 -0
  99. package/composables/useSiteSettings.ts +50 -0
  100. package/composables/useThemePreference.ts +63 -0
  101. package/composables/useVisitor.ts +6 -1
  102. package/composables/useVisitorSocket.ts +26 -0
  103. package/composables/useWebUsb.ts +127 -37
  104. package/nuxt.config.ts +31 -0
  105. package/package.json +4 -1
  106. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  107. package/pages/notification-settings.vue +19 -0
  108. package/plugins/secure-member.client.ts +21 -56
  109. package/plugins/vuetify.ts +68 -9
  110. package/test/visitor-socket.test.mjs +36 -0
  111. package/types/org.d.ts +2 -0
  112. package/types/site.d.ts +71 -0
  113. package/utils/camera-wall.test.ts +571 -0
  114. package/utils/camera-wall.ts +926 -0
  115. package/utils/nav-sections.test.ts +190 -0
  116. package/utils/nav-sections.ts +151 -0
  117. package/utils/status.test.ts +77 -0
  118. package/utils/status.ts +90 -0
  119. package/utils/theme.test.ts +534 -0
  120. package/utils/theme.ts +294 -0
  121. package/components/HidIdentityMapping.vue +0 -975
  122. package/middleware/member.ts +0 -4
  123. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -0,0 +1,20 @@
1
+ <!--
2
+ THE CARD.
3
+
4
+ 14px radius, a 1px `--border` rule and the design's one-pixel shadow, on
5
+ `--card`. A plain element rather than a `v-card`, because `v-card` brings its
6
+ own elevation, radius and surface colour at a specificity that a class has to
7
+ fight - which is why the radius kept coming back as 4px in Phase 3.
8
+ -->
9
+ <template>
10
+ <div class="app-card" :class="{ 'app-card--flush': flush }">
11
+ <slot />
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ defineProps({
17
+ /** Let content (a menu, a dropdown) escape the card's rounded corners. */
18
+ flush: { type: Boolean, default: false },
19
+ });
20
+ </script>
@@ -0,0 +1,35 @@
1
+ <!--
2
+ THE DATE FIELD.
3
+
4
+ The same 40px box with a calendar glyph, as the handoff draws the two date
5
+ ends of a filter row. `<input type="date">` - the browser's own picker, which
6
+ is the one a phone user already knows and the one that is localised, keyboard
7
+ accessible and free.
8
+ -->
9
+ <template>
10
+ <label class="app-field" :class="{ 'app-field--compact': compact }">
11
+ <v-icon class="app-field__icon" icon="mdi-calendar-month-outline" size="17" />
12
+ <span v-if="label" class="app-field__label">{{ label }}</span>
13
+ <input
14
+ class="app-field__input"
15
+ type="date"
16
+ :value="model"
17
+ :min="min || undefined"
18
+ :max="max || undefined"
19
+ :disabled="disabled"
20
+ @input="model = ($event.target as HTMLInputElement).value"
21
+ />
22
+ </label>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ const model = defineModel({ type: String, default: "" });
27
+
28
+ defineProps({
29
+ label: { type: String, default: "" },
30
+ min: { type: String, default: "" },
31
+ max: { type: String, default: "" },
32
+ compact: { type: Boolean, default: false },
33
+ disabled: { type: Boolean, default: false },
34
+ });
35
+ </script>
@@ -0,0 +1,46 @@
1
+ <!--
2
+ THE TEXT FIELD.
3
+
4
+ 40px (34px compact), 10px radius, a 1px border, 13.5px/500 - and the label
5
+ OUTSIDE the box as a small muted word, not Vuetify's outlined field with a
6
+ label that floats up through the border. A native `<input>`, so the browser
7
+ keeps the keyboard, autofill and accessibility for free.
8
+ -->
9
+ <template>
10
+ <label
11
+ class="app-field"
12
+ :class="[{ 'app-field--compact': compact, 'app-field--search': search }, klass]"
13
+ >
14
+ <v-icon
15
+ v-if="icon || search"
16
+ class="app-field__icon"
17
+ :icon="icon || 'mdi-magnify'"
18
+ size="18"
19
+ />
20
+ <span v-if="label" class="app-field__label">{{ label }}</span>
21
+ <input
22
+ class="app-field__input"
23
+ :type="type"
24
+ :value="model"
25
+ :placeholder="placeholder"
26
+ :disabled="disabled"
27
+ @input="model = ($event.target as HTMLInputElement).value"
28
+ />
29
+ </label>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ const model = defineModel({ type: String, default: "" });
34
+
35
+ defineProps({
36
+ label: { type: String, default: "" },
37
+ placeholder: { type: String, default: "" },
38
+ icon: { type: String, default: "" },
39
+ type: { type: String, default: "text" },
40
+ /** The design's search field: a magnifier and `flex:1` in a filter row. */
41
+ search: { type: Boolean, default: false },
42
+ compact: { type: Boolean, default: false },
43
+ disabled: { type: Boolean, default: false },
44
+ klass: { type: String, default: "" },
45
+ });
46
+ </script>
@@ -0,0 +1,65 @@
1
+ <!--
2
+ THE KPI TILE.
3
+
4
+ The handoff's tile is three rows: the soft icon square AND the uppercase
5
+ label together on the first, the big tabular number with its percentage pill
6
+ BESIDE it on the second, the sub-line on the third. The product had the icon
7
+ alone on a row of its own, the label under it, and the pill stranded at the
8
+ bottom next to the sub-line - the same figures in a different shape.
9
+
10
+ It draws what it is given and nothing else: no fetch, no formatting, no
11
+ threshold logic. `#action` is the far end of the first row (a filter menu, a
12
+ "View All"); `#default`, when given, replaces the figure rows entirely for
13
+ the tiles that list items instead of counting them.
14
+ -->
15
+ <template>
16
+ <AppCard class="app-kpi d-flex flex-column flex-grow-1">
17
+ <div class="app-kpi__head">
18
+ <span
19
+ v-if="icon"
20
+ class="app-kpi__icon"
21
+ :style="tint ? { backgroundColor: tint } : undefined"
22
+ >
23
+ <v-icon :icon="icon" :color="color || undefined" size="19" />
24
+ </span>
25
+
26
+ <span class="app-kpi__label">{{ label }}</span>
27
+
28
+ <span v-if="$slots.action" class="app-kpi__action">
29
+ <slot name="action" />
30
+ </span>
31
+ </div>
32
+
33
+ <slot>
34
+ <div class="app-kpi__figure">
35
+ <span class="app-kpi__value">{{ value }}</span>
36
+
37
+ <span
38
+ v-if="chip"
39
+ class="app-kpi__chip"
40
+ :class="{ 'app-kpi__chip--down': down }"
41
+ >
42
+ {{ chip }}
43
+ </span>
44
+ </div>
45
+
46
+ <div v-if="sub" class="app-kpi__sub">{{ sub }}</div>
47
+ </slot>
48
+ </AppCard>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ defineProps({
53
+ icon: { type: String, default: "" },
54
+ /** The icon square's soft fill - a token-derived colour from the caller. */
55
+ tint: { type: String, default: "" },
56
+ color: { type: String, default: "" },
57
+ label: { type: String, default: "" },
58
+ value: { type: [String, Number], default: "" },
59
+ /** The percentage pill's text, already formatted by the caller. */
60
+ chip: { type: String, default: "" },
61
+ /** Draws the pill in the error tone rather than the success one. */
62
+ down: { type: Boolean, default: false },
63
+ sub: { type: String, default: "" },
64
+ });
65
+ </script>
@@ -0,0 +1,38 @@
1
+ <!--
2
+ THE SEGMENTED CONTROL - `Today | Week | Month`.
3
+
4
+ A tray of plain `<button>`s, not a `v-btn-toggle`. The toggle brings its own
5
+ height scale, dividers, ripple and overlay layer, and its selected state is
6
+ drawn from `on-surface` - which is why the product's selected segment reads
7
+ grey where the design fills it with the accent.
8
+
9
+ Selection only: it writes the same `v-model` the toggle wrote, and the value
10
+ set is whatever the caller passes. No behaviour is added here.
11
+ -->
12
+ <template>
13
+ <div class="app-seg" role="group">
14
+ <button
15
+ v-for="option in options"
16
+ :key="String(option.value)"
17
+ type="button"
18
+ class="app-seg__item"
19
+ :class="{ 'app-seg__item--on': model === option.value }"
20
+ :aria-pressed="model === option.value"
21
+ @click="model = option.value"
22
+ >
23
+ {{ option.label }}
24
+ </button>
25
+ </div>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ defineProps({
30
+ /** `{ label, value }` - the same option list the toggle was given. */
31
+ options: {
32
+ type: Array as PropType<Array<{ label: string; value: any }>>,
33
+ default: () => [],
34
+ },
35
+ });
36
+
37
+ const model = defineModel<any>({ default: "" });
38
+ </script>
@@ -0,0 +1,203 @@
1
+ <!--
2
+ THE SELECT.
3
+
4
+ The handoff draws it as the text field with the filter's name in muted 12px
5
+ on the left, the chosen value next to it and a chevron on the right. A native
6
+ `<select>` under our chrome: the option list, the keyboard and the mobile
7
+ wheel are the browser's, so there is no menu to build and nothing to get
8
+ wrong on a phone.
9
+
10
+ `multiple` is the ONE case where native is the wrong answer. A native
11
+ `<select multiple>` is not a dropdown at all - it is an always-open scrolling
12
+ LIST BOX - so adopting it would turn the daily filter bars into list boxes
13
+ and change how the staff work. So multi opens a POPOVER in the same field
14
+ chrome, with the chosen values as the design's soft removable pills.
15
+
16
+ The single-value path below is byte-for-byte the one it has always been: a
17
+ consumer that passes neither `multiple` nor `return-object` still emits
18
+ exactly the `<select>` element's own string.
19
+ -->
20
+ <template>
21
+ <label
22
+ v-if="!multiple"
23
+ v-bind="$attrs"
24
+ class="app-field"
25
+ :class="{ 'app-field--compact': compact }"
26
+ >
27
+ <span v-if="label" class="app-field__label">{{ label }}</span>
28
+ <select
29
+ class="app-field__input"
30
+ :value="singleValue"
31
+ :disabled="disabled"
32
+ @change="onNativeChange"
33
+ >
34
+ <!-- `clearable` on a native select is the empty option, not an x. -->
35
+ <option v-if="clearable" value="">{{ placeholder }}</option>
36
+ <option v-for="opt in normalized" :key="String(opt.value)" :value="opt.value">
37
+ {{ opt.title }}
38
+ </option>
39
+ </select>
40
+ <v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
41
+ </label>
42
+
43
+ <v-menu
44
+ v-else
45
+ :close-on-content-click="false"
46
+ :disabled="disabled"
47
+ location="bottom start"
48
+ offset="4"
49
+ >
50
+ <template #activator="{ props: activator }">
51
+ <div
52
+ v-bind="{ ...activator, ...$attrs }"
53
+ class="app-field app-select"
54
+ :class="{ 'app-field--compact': compact, 'app-select--disabled': disabled }"
55
+ role="button"
56
+ :aria-disabled="disabled"
57
+ :tabindex="disabled ? -1 : 0"
58
+ >
59
+ <span v-if="label" class="app-field__label">{{ label }}</span>
60
+ <span class="app-select__values">
61
+ <StatusChip
62
+ v-for="opt in selectedOptions"
63
+ :key="String(opt.value)"
64
+ tone="neutral"
65
+ pill
66
+ :dot="false"
67
+ >
68
+ {{ opt.title }}
69
+ <button
70
+ type="button"
71
+ class="app-select__x"
72
+ :aria-label="`Remove ${opt.title}`"
73
+ @click.stop="toggle(opt)"
74
+ >
75
+ <v-icon icon="mdi-close" size="12" />
76
+ </button>
77
+ </StatusChip>
78
+ <span v-if="!selectedOptions.length" class="app-select__placeholder">
79
+ {{ placeholder }}
80
+ </span>
81
+ </span>
82
+ <button
83
+ v-if="clearable && selectedOptions.length"
84
+ type="button"
85
+ class="app-select__clear"
86
+ aria-label="Clear"
87
+ @click.stop="clear"
88
+ >
89
+ <v-icon icon="mdi-close-circle" size="15" />
90
+ </button>
91
+ <v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
92
+ </div>
93
+ </template>
94
+
95
+ <div class="app-select__menu">
96
+ <button
97
+ v-for="opt in normalized"
98
+ :key="String(opt.value)"
99
+ type="button"
100
+ class="app-select__option"
101
+ :aria-pressed="isOn(opt)"
102
+ @click="toggle(opt)"
103
+ >
104
+ <v-icon
105
+ class="app-select__box"
106
+ :class="{ 'app-select__box--on': isOn(opt) }"
107
+ :icon="isOn(opt) ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'"
108
+ size="17"
109
+ />
110
+ <span class="app-select__option-text">{{ opt.title }}</span>
111
+ </button>
112
+ </div>
113
+ </v-menu>
114
+ </template>
115
+
116
+ <script setup lang="ts">
117
+ /* `v-menu` has no single root node, so a class on `<AppSelect multiple>` has
118
+ to be put on the activator by hand. The single branch lands where it always
119
+ did - on the `<label>`. */
120
+ defineOptions({ inheritAttrs: false });
121
+
122
+ /*
123
+ * One model for both shapes: a string when single, an array when multiple.
124
+ * The default stays `""` so an existing single consumer that never sets its
125
+ * v-model starts on the same empty string it starts on today.
126
+ */
127
+ const model = defineModel<any>({ default: "" });
128
+
129
+ const props = defineProps({
130
+ label: { type: String, default: "" },
131
+ /** Strings, or the `{ title, value }` shape `v-select` already takes. */
132
+ items: { type: Array as PropType<any[]>, default: () => [] },
133
+ itemTitle: { type: String, default: "title" },
134
+ itemValue: { type: String, default: "value" },
135
+ compact: { type: Boolean, default: false },
136
+ disabled: { type: Boolean, default: false },
137
+ /** Popover multi-select with removable pills. Never a native list box. */
138
+ multiple: { type: Boolean, default: false },
139
+ /** Emit the source item rather than its `itemValue`. */
140
+ returnObject: { type: Boolean, default: false },
141
+ clearable: { type: Boolean, default: false },
142
+ placeholder: { type: String, default: "" },
143
+ });
144
+
145
+ const normalized = computed(() =>
146
+ props.items.map((item: any) =>
147
+ typeof item === "object" && item !== null
148
+ ? { title: item[props.itemTitle], value: item[props.itemValue], raw: item }
149
+ : { title: String(item), value: item, raw: item }
150
+ )
151
+ );
152
+
153
+ /* A model entry is either the plain value or - under `return-object` - the
154
+ source item, so everything compares on this one key. */
155
+ const keyOf = (v: any) =>
156
+ String(v !== null && typeof v === "object" ? v[props.itemValue] : v);
157
+
158
+ /* ---- single ---------------------------------------------------------- */
159
+
160
+ const singleValue = computed(() =>
161
+ props.returnObject ? keyOf(model.value) : model.value
162
+ );
163
+
164
+ function onNativeChange(event: Event) {
165
+ const value = (event.target as HTMLSelectElement).value;
166
+ model.value = props.returnObject
167
+ ? normalized.value.find((o) => String(o.value) === value)?.raw ?? value
168
+ : value;
169
+ }
170
+
171
+ /* ---- multiple -------------------------------------------------------- */
172
+
173
+ const selected = computed<any[]>(() =>
174
+ Array.isArray(model.value) ? model.value : []
175
+ );
176
+
177
+ const selectedKeys = computed(() => new Set(selected.value.map(keyOf)));
178
+
179
+ const isOn = (opt: any) => selectedKeys.value.has(String(opt.value));
180
+
181
+ /* Fall back to the raw key when a chosen value is not in `items` - an async
182
+ item list can arrive after the model does, and the chip must still print. */
183
+ const selectedOptions = computed(() =>
184
+ selected.value.map(
185
+ (v) =>
186
+ normalized.value.find((o) => String(o.value) === keyOf(v)) ?? {
187
+ title: keyOf(v),
188
+ value: keyOf(v),
189
+ }
190
+ )
191
+ );
192
+
193
+ function toggle(opt: any) {
194
+ if (props.disabled) return;
195
+ model.value = isOn(opt)
196
+ ? selected.value.filter((v) => keyOf(v) !== String(opt.value))
197
+ : [...selected.value, props.returnObject ? opt.raw : opt.value];
198
+ }
199
+
200
+ function clear() {
201
+ model.value = [];
202
+ }
203
+ </script>
@@ -33,7 +33,7 @@
33
33
  <template #extension>
34
34
  <v-row no-gutters class="w-100 d-flex flex-column">
35
35
  <v-card
36
- class="w-100 px-3 d-flex align-center ga-5 py-2"
36
+ class="w-100 filter-bar d-flex align-center ga-5"
37
37
  flat
38
38
  :height="60"
39
39
  >
@@ -80,7 +80,11 @@
80
80
  </div>
81
81
  </template>
82
82
  <template #item.status="{ value }">
83
- <v-chip class="text-capitalize">{{ value || "No Status" }}</v-chip>
83
+ <StatusChip
84
+ class="text-capitalize"
85
+ :status="value"
86
+ :label="value || 'No Status'"
87
+ />
84
88
  </template>
85
89
  </TableMain></v-col
86
90
  >
@@ -49,10 +49,8 @@
49
49
  <!-- Primary action -->
50
50
  <v-btn
51
51
  v-if="canCreateArea"
52
- class="text-none mr-3"
53
- rounded="pill"
54
- variant="tonal"
55
- size="large"
52
+ class="text-none mr-3 screen-btn-primary"
53
+ variant="flat"
56
54
  @click="onCreateItem"
57
55
  >
58
56
  {{ createLabel }}
@@ -63,10 +61,8 @@
63
61
  <template #activator="{ props: t }">
64
62
  <v-btn
65
63
  v-bind="t"
66
- class="ma-1"
67
- variant="tonal"
68
- size="large"
69
- rounded="pill"
64
+ class="ma-1 screen-icon-btn"
65
+ variant="flat"
70
66
  href="/files/area-sample-data.xlsx"
71
67
  target="_blank"
72
68
  download
@@ -81,10 +77,8 @@
81
77
  <template #activator="{ props: t }">
82
78
  <v-btn
83
79
  v-bind="t"
84
- class="ma-1"
85
- variant="tonal"
86
- size="large"
87
- rounded="pill"
80
+ class="ma-1 screen-icon-btn"
81
+ variant="flat"
88
82
  @click="onImportData"
89
83
  >
90
84
  <v-icon>mdi-upload</v-icon>
@@ -97,10 +91,8 @@
97
91
  <template #activator="{ props: t }">
98
92
  <v-btn
99
93
  v-bind="t"
100
- class="ma-1"
101
- variant="tonal"
102
- size="large"
103
- rounded="pill"
94
+ class="ma-1 screen-icon-btn"
95
+ variant="flat"
104
96
  @click="handleDownloadExcel"
105
97
  :loading="isDownloading"
106
98
  >
@@ -114,7 +106,7 @@
114
106
 
115
107
  <v-spacer />
116
108
 
117
- <v-col cols="auto">
109
+ <v-col cols="auto" class="filter-field">
118
110
  <v-text-field
119
111
  v-model="searchInput"
120
112
  density="compact"
@@ -15,11 +15,12 @@
15
15
  @row-click="handleRowClick"
16
16
  >
17
17
  <template #actions>
18
+ <!-- Was `color="black"`: a black slab in both themes, and the only
19
+ black surface on the page. Same button, the design's ghost. -->
18
20
  <v-btn
19
21
  v-if="canManageAttendanceSettings"
20
- variant="flat"
21
- color="black"
22
- class="text-none"
22
+ variant="outlined"
23
+ class="text-none screen-btn-ghost"
23
24
  @click="dialogShowSettings = true"
24
25
  >
25
26
  <v-icon class="mr-2">mdi-cog</v-icon>
@@ -1,6 +1,18 @@
1
1
  <template>
2
2
  <v-avatar :color="getColor(name || id)" :size="size">
3
- <v-img v-if="imageSrc" alt="John" :src="imageUrl" />
3
+ <!--
4
+ A stored profile photo that 404s left the circle EMPTY - `v-img`
5
+ draws nothing when the source fails, and there was no fallback under
6
+ it. The initials the avatar already computes stand in, so the circle
7
+ is never blank.
8
+ -->
9
+ <v-img v-if="imageSrc" :alt="name" :src="imageUrl">
10
+ <template #error>
11
+ <div class="d-flex align-center justify-center fill-height">
12
+ <span :style="{ fontSize: `${fontSize}px` }">{{ initials }}</span>
13
+ </div>
14
+ </template>
15
+ </v-img>
4
16
  <span v-else :style="{
5
17
  fontSize: `${fontSize}px`,
6
18
  lineHeight: `${size}px`
@@ -43,6 +55,39 @@ const initials = computed(() => {
43
55
 
44
56
  const fontSize = computed(() => Math.round(props.size * 0.5))
45
57
 
58
+ /**
59
+ * WHITE INITIALS ON A HASHED COLOUR - THE ESTATE'S WORST CONTRAST DEFECT.
60
+ *
61
+ * The circle's fill is derived from the name, and Vuetify then picks a WHITE
62
+ * label for it. Measured across the whole space this generator can produce
63
+ * (360 hues x 20 saturations x 15 lightnesses): **70.6% of names fail AA**,
64
+ * and the worst - a yellow, `hsl(60,79%,59%)` - reads **1.30:1**. The 3.12:1
65
+ * seen on one screen was a mild sample, not the floor.
66
+ *
67
+ * The name's colour is its identity, so the HUE and SATURATION are kept
68
+ * exactly; only the lightness is walked down until white clears 4.5:1 on it.
69
+ * A yellow therefore darkens a long way and a blue barely moves, which is what
70
+ * makes a single fixed lightness impossible here.
71
+ */
72
+ const AA = 4.5
73
+
74
+ const srgb = (c: number) => {
75
+ const v = c / 255
76
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
77
+ }
78
+
79
+ /** Relative luminance of an `hsl()` triple, without going through the DOM. */
80
+ function luminance(h: number, s: number, l: number): number {
81
+ const sat = s / 100
82
+ const light = l / 100
83
+ const a = sat * Math.min(light, 1 - light)
84
+ const f = (n: number) => {
85
+ const k = (n + h / 30) % 12
86
+ return light - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
87
+ }
88
+ return 0.2126 * srgb(f(0) * 255) + 0.7152 * srgb(f(8) * 255) + 0.0722 * srgb(f(4) * 255)
89
+ }
90
+
46
91
  function getColor(str?: string): string {
47
92
  if(!str) return "grey"
48
93
 
@@ -54,7 +99,14 @@ function getColor(str?: string): string {
54
99
 
55
100
  const hue = Math.abs(hash) % 360
56
101
  const saturation = 60 + (Math.abs((hash >> 3) % 20)) // 60–80%
57
- const lightness = 45 + (Math.abs((hash >> 5) % 15)) // 45–60%
102
+ let lightness = 45 + (Math.abs((hash >> 5) % 15)) // 45–60%
103
+
104
+ // ponytail: linear 1% walk, not a solve - the range is at most 45 steps and
105
+ // this runs once per avatar. Lightness bottoms out at 0, where every hue is
106
+ // black and white is 21:1, so the loop always terminates on a pass.
107
+ while (lightness > 0 && 1.05 / (luminance(hue, saturation, lightness) + 0.05) < AA) {
108
+ lightness -= 1
109
+ }
58
110
 
59
111
  return `hsl(${hue}, ${saturation}%, ${lightness}%)`
60
112
  }
@@ -1,30 +1,27 @@
1
1
  <template>
2
- <v-row no-gutters>
3
- <v-col cols="12">
4
- <v-card
5
- width="100%"
6
- variant="outlined"
7
- border="thin"
8
- rounded="lg"
9
- :loading="false"
10
- >
11
- <v-toolbar density="compact" color="grey-lighten-4">
12
- <template #prepend>
13
- <v-btn fab icon density="comfortable">
14
- <v-icon>mdi-refresh</v-icon>
15
- </v-btn>
16
- </template>
17
- <template #append>
18
- <v-row no-gutters justify="end" align="center">
19
- <span class="mr-2 text-caption text-fontgray">
20
- {{ pageRange }}
21
- </span>
22
- <local-pagination v-model="page" :length="pages" />
23
- </v-row>
24
- </template>
25
- </v-toolbar>
2
+ <div>
3
+ <!--
4
+ THE DESIGN'S PAGE SCAFFOLD (same shape as Service Providers).
5
+
6
+ Placement only: the same refresh control, the same range text, the same
7
+ pager bound to the same `page`, and the table, its columns and its cell
8
+ templates are untouched. The screen simply gains the page title it has
9
+ never had, and its card + toolbar are the design's rather than Vuetify's.
10
+ -->
11
+ <PageHeader title="Billing" />
12
+
13
+ <AppCard class="table-card">
14
+ <CardToolbar :count="pageRange">
15
+ <template #left>
16
+ <AppButton variant="icon" icon="mdi-refresh" aria-label="Refresh" />
17
+ </template>
18
+
19
+ <template #right>
20
+ <local-pagination v-model="page" :length="pages" />
21
+ </template>
22
+ </CardToolbar>
26
23
 
27
- <v-data-table
24
+ <v-data-table
28
25
  :headers="headers"
29
26
  :items="items"
30
27
  item-value="_id"
@@ -38,7 +35,7 @@
38
35
  <span class="text-caption font-weight-bold text-capitalize">
39
36
  permissions
40
37
  </span>
41
- <v-chip>{{ value.length }}</v-chip>
38
+ <StatusChip :dot="false" tone="neutral" :label="`${value.length}`" />
42
39
  </template>
43
40
 
44
41
  <template #item.nature="{ item }">
@@ -46,10 +43,9 @@
46
43
  {{ item.nature }}
47
44
  </span>
48
45
  </template>
49
- </v-data-table>
50
- </v-card>
51
- </v-col>
52
- </v-row>
46
+ </v-data-table>
47
+ </AppCard>
48
+ </div>
53
49
  </template>
54
50
 
55
51
  <script setup lang="ts">