@7365admin1/layer-common 3.2.2-staging.90 → 3.2.2-staging.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/css/primitives.css +563 -0
- package/components/AppButton.vue +52 -0
- package/components/AppCard.vue +20 -0
- package/components/AppDateField.vue +35 -0
- package/components/AppField.vue +46 -0
- package/components/AppKpiTile.vue +65 -0
- package/components/AppSegmented.vue +38 -0
- package/components/AppSelect.vue +47 -0
- package/components/Avatar/Main.vue +13 -1
- package/components/CardToolbar.vue +37 -0
- package/components/DashboardMain.vue +67 -122
- package/components/Layout/Header.vue +16 -2
- package/components/MemberMain.vue +79 -63
- package/components/PageHeader.vue +40 -0
- package/components/StatusChip.vue +17 -2
- package/components/SwitchContext.vue +18 -0
- package/package.json +1 -1
- package/plugins/vuetify.ts +1 -0
|
@@ -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,47 @@
|
|
|
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
|
+
<template>
|
|
11
|
+
<label class="app-field" :class="{ 'app-field--compact': compact }">
|
|
12
|
+
<span v-if="label" class="app-field__label">{{ label }}</span>
|
|
13
|
+
<select
|
|
14
|
+
class="app-field__input"
|
|
15
|
+
:value="model"
|
|
16
|
+
:disabled="disabled"
|
|
17
|
+
@change="model = ($event.target as HTMLSelectElement).value"
|
|
18
|
+
>
|
|
19
|
+
<option v-for="opt in normalized" :key="opt.value" :value="opt.value">
|
|
20
|
+
{{ opt.title }}
|
|
21
|
+
</option>
|
|
22
|
+
</select>
|
|
23
|
+
<v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
|
|
24
|
+
</label>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
const model = defineModel({ type: String, default: "" });
|
|
29
|
+
|
|
30
|
+
const props = defineProps({
|
|
31
|
+
label: { type: String, default: "" },
|
|
32
|
+
/** Strings, or the `{ title, value }` shape `v-select` already takes. */
|
|
33
|
+
items: { type: Array as PropType<any[]>, default: () => [] },
|
|
34
|
+
itemTitle: { type: String, default: "title" },
|
|
35
|
+
itemValue: { type: String, default: "value" },
|
|
36
|
+
compact: { type: Boolean, default: false },
|
|
37
|
+
disabled: { type: Boolean, default: false },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const normalized = computed(() =>
|
|
41
|
+
props.items.map((item: any) =>
|
|
42
|
+
typeof item === "object" && item !== null
|
|
43
|
+
? { title: item[props.itemTitle], value: item[props.itemValue] }
|
|
44
|
+
: { title: String(item), value: item }
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
</script>
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-avatar :color="getColor(name || id)" :size="size">
|
|
3
|
-
|
|
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`
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
THE IN-CARD TOP ROW.
|
|
3
|
+
|
|
4
|
+
The design does NOT put a screen's tabs above its card - they are the card's
|
|
5
|
+
own first row, with the pagination on the opposite end of that same line, and
|
|
6
|
+
the tab underline meets the card's rule. Today those tabs sit in a
|
|
7
|
+
`v-toolbar`'s extension slot, which stacks them BELOW a separate bar and
|
|
8
|
+
costs a whole row of height.
|
|
9
|
+
|
|
10
|
+
Two shapes, both from the handoff: with tabs (`0 20px`, the tabs supply the
|
|
11
|
+
vertical padding) and without (`10px 14px`, a refresh button on the left).
|
|
12
|
+
This component only places things - the tabs, the count and the pager are
|
|
13
|
+
passed in and keep their own behaviour.
|
|
14
|
+
-->
|
|
15
|
+
<template>
|
|
16
|
+
<div class="app-toolbar" :class="{ 'app-toolbar--plain': !$slots.tabs }">
|
|
17
|
+
<div v-if="$slots.tabs" class="app-tabs" role="tablist">
|
|
18
|
+
<slot name="tabs" />
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<slot name="left" />
|
|
22
|
+
|
|
23
|
+
<div class="app-toolbar__spacer" />
|
|
24
|
+
|
|
25
|
+
<div class="app-toolbar__right">
|
|
26
|
+
<span v-if="count" class="app-toolbar__count">{{ count }}</span>
|
|
27
|
+
<slot name="right" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
defineProps({
|
|
34
|
+
/** The design's `1-1 of 1` range, printed as the screen already computes it. */
|
|
35
|
+
count: { type: String, default: "" },
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
@@ -203,44 +203,36 @@
|
|
|
203
203
|
|
|
204
204
|
<section v-else class="unified-dashboard pb-8">
|
|
205
205
|
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
|
206
|
-
<
|
|
207
|
-
|
|
206
|
+
<PageHeader
|
|
207
|
+
:title="dashboardTitle"
|
|
208
|
+
:subtitle="currentSiteName || 'Selected site'"
|
|
209
|
+
subtitle-icon="mdi-map-marker"
|
|
208
210
|
>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<div
|
|
220
|
-
v-if="showHeaderActions"
|
|
221
|
-
class="d-flex flex-wrap align-center justify-md-end ga-3"
|
|
222
|
-
>
|
|
223
|
-
<v-btn
|
|
224
|
-
variant="outlined"
|
|
225
|
-
class="text-none font-weight-bold"
|
|
226
|
-
prepend-icon="mdi-tune-variant"
|
|
211
|
+
<!--
|
|
212
|
+
The design's three actions: two 38px ghost buttons and the accent
|
|
213
|
+
segmented control. Same dialog, same export menu, same range model -
|
|
214
|
+
`Customize Dashboard` and `Export` shorten to the labels the prototype
|
|
215
|
+
prints, and the export menu keeps both of its formats.
|
|
216
|
+
-->
|
|
217
|
+
<template v-if="showHeaderActions" #actions>
|
|
218
|
+
<AppButton
|
|
219
|
+
variant="ghost"
|
|
220
|
+
icon="mdi-tune-variant"
|
|
227
221
|
@click="openCustomizeDialog"
|
|
228
222
|
>
|
|
229
|
-
Customize
|
|
230
|
-
</
|
|
223
|
+
Customize
|
|
224
|
+
</AppButton>
|
|
231
225
|
|
|
232
226
|
<v-menu>
|
|
233
227
|
<template #activator="{ props: menuProps }">
|
|
234
|
-
<
|
|
228
|
+
<AppButton
|
|
235
229
|
v-bind="menuProps"
|
|
236
|
-
variant="
|
|
237
|
-
|
|
238
|
-
append-icon="mdi-chevron-down"
|
|
239
|
-
:loading="isExporting"
|
|
230
|
+
variant="ghost"
|
|
231
|
+
icon="mdi-download-outline"
|
|
240
232
|
:disabled="isExporting"
|
|
241
233
|
>
|
|
242
234
|
Export
|
|
243
|
-
</
|
|
235
|
+
</AppButton>
|
|
244
236
|
</template>
|
|
245
237
|
|
|
246
238
|
<v-list density="compact" border rounded="lg" elevation="3">
|
|
@@ -249,36 +241,9 @@
|
|
|
249
241
|
</v-list>
|
|
250
242
|
</v-menu>
|
|
251
243
|
|
|
252
|
-
<v-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
density="comfortable"
|
|
256
|
-
divided
|
|
257
|
-
variant="outlined"
|
|
258
|
-
class="bg-surface"
|
|
259
|
-
style="border-radius: 8px"
|
|
260
|
-
>
|
|
261
|
-
<v-btn
|
|
262
|
-
v-for="range in rangeOptions"
|
|
263
|
-
:key="range.value"
|
|
264
|
-
:value="range.value"
|
|
265
|
-
class="text-none"
|
|
266
|
-
size="small"
|
|
267
|
-
:style="
|
|
268
|
-
selectedRange === range.value
|
|
269
|
-
? {
|
|
270
|
-
backgroundColor: 'rgba(var(--v-theme-on-surface), 0.12)',
|
|
271
|
-
color: 'rgb(var(--v-theme-on-surface))',
|
|
272
|
-
fontWeight: '600',
|
|
273
|
-
}
|
|
274
|
-
: { backgroundColor: 'transparent', color: 'rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))' }
|
|
275
|
-
"
|
|
276
|
-
>
|
|
277
|
-
{{ range.label }}
|
|
278
|
-
</v-btn>
|
|
279
|
-
</v-btn-toggle>
|
|
280
|
-
</div>
|
|
281
|
-
</div>
|
|
244
|
+
<AppSegmented v-model="selectedRange" :options="rangeOptions" />
|
|
245
|
+
</template>
|
|
246
|
+
</PageHeader>
|
|
282
247
|
|
|
283
248
|
<v-progress-linear
|
|
284
249
|
v-if="finalLoading"
|
|
@@ -298,20 +263,24 @@
|
|
|
298
263
|
lg="3"
|
|
299
264
|
class="d-flex"
|
|
300
265
|
>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
266
|
+
<!--
|
|
267
|
+
The same figures the tile has always shown, in the arrangement the
|
|
268
|
+
handoff draws them: icon and label on one row, the number with its
|
|
269
|
+
percentage pill beside it, the sub-line under both. The filter menu
|
|
270
|
+
and the facility "View All" keep their place at the end of the first
|
|
271
|
+
row and do exactly what they did.
|
|
272
|
+
-->
|
|
273
|
+
<AppKpiTile
|
|
274
|
+
:icon="card.icon"
|
|
275
|
+
:tint="card.tint"
|
|
276
|
+
:color="card.color"
|
|
277
|
+
:label="card.title"
|
|
278
|
+
:value="card.value"
|
|
279
|
+
:chip="kpiChipText(card)"
|
|
280
|
+
:down="Number(card.percentage) < 0"
|
|
281
|
+
:sub="card.meta"
|
|
305
282
|
>
|
|
306
|
-
<
|
|
307
|
-
<div
|
|
308
|
-
class="d-inline-flex align-center justify-center rounded-lg"
|
|
309
|
-
style="height: 28px; width: 28px; flex-shrink: 0"
|
|
310
|
-
:style="{ backgroundColor: card.tint }"
|
|
311
|
-
>
|
|
312
|
-
<v-icon :icon="card.icon" :color="card.color" size="15" />
|
|
313
|
-
</div>
|
|
314
|
-
|
|
283
|
+
<template v-if="card.filter || card.key === 'facilityBookings'" #action>
|
|
315
284
|
<v-menu v-if="card.filter" v-model="filterMenus[card.key]">
|
|
316
285
|
<template #activator="{ props: menuProps }">
|
|
317
286
|
<v-btn
|
|
@@ -355,59 +324,24 @@
|
|
|
355
324
|
>
|
|
356
325
|
View All
|
|
357
326
|
</button>
|
|
358
|
-
</
|
|
327
|
+
</template>
|
|
359
328
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
<
|
|
371
|
-
|
|
372
|
-
:key="item._id || item.name"
|
|
373
|
-
class="d-flex justify-space-between text-body-2 text-high-emphasis font-weight-medium"
|
|
374
|
-
>
|
|
375
|
-
<span>{{ item.name }}</span>
|
|
376
|
-
<span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
|
|
377
|
-
</div>
|
|
378
|
-
</div>
|
|
379
|
-
</template>
|
|
380
|
-
<template v-else>
|
|
381
|
-
<div class="d-flex align-center mb-3" style="min-height: 31px">
|
|
382
|
-
<span class="dashboard-kpi-value">
|
|
383
|
-
{{ card.value }}
|
|
384
|
-
</span>
|
|
385
|
-
</div>
|
|
386
|
-
<div class="d-flex align-center flex-wrap ga-2">
|
|
387
|
-
<span v-if="card.meta" class="text-caption text-medium-emphasis">{{
|
|
388
|
-
card.meta
|
|
389
|
-
}}</span>
|
|
390
|
-
<v-chip
|
|
391
|
-
:color="card.percentage >= 0 ? 'success' : 'error'"
|
|
392
|
-
size="x-small"
|
|
393
|
-
variant="flat"
|
|
394
|
-
class="font-weight-bold"
|
|
395
|
-
>
|
|
396
|
-
<v-icon
|
|
397
|
-
:icon="
|
|
398
|
-
card.percentage >= 0
|
|
399
|
-
? 'mdi-arrow-up-right'
|
|
400
|
-
: 'mdi-arrow-down-right'
|
|
401
|
-
"
|
|
402
|
-
size="13"
|
|
403
|
-
start
|
|
404
|
-
/>
|
|
405
|
-
{{ Math.abs(card.percentage) }}%
|
|
406
|
-
</v-chip>
|
|
329
|
+
<!-- The supply tile lists items instead of counting one figure. -->
|
|
330
|
+
<template
|
|
331
|
+
v-if="card.key === 'supplyAlert' && Array.isArray(card.raw?.items)"
|
|
332
|
+
>
|
|
333
|
+
<div class="d-flex flex-column ga-2">
|
|
334
|
+
<div
|
|
335
|
+
v-for="item in card.raw.items"
|
|
336
|
+
:key="item._id || item.name"
|
|
337
|
+
class="d-flex justify-space-between text-body-2 text-high-emphasis font-weight-medium"
|
|
338
|
+
>
|
|
339
|
+
<span>{{ item.name }}</span>
|
|
340
|
+
<span class="text-medium-emphasis">Balance: {{ item.qty }}</span>
|
|
407
341
|
</div>
|
|
408
|
-
</
|
|
409
|
-
</
|
|
410
|
-
</
|
|
342
|
+
</div>
|
|
343
|
+
</template>
|
|
344
|
+
</AppKpiTile>
|
|
411
345
|
</v-col>
|
|
412
346
|
</v-row>
|
|
413
347
|
|
|
@@ -2491,6 +2425,17 @@ const visibleKpiCards = computed(() =>
|
|
|
2491
2425
|
kpiCards.value.filter((card) => isWidgetVisible(card.key))
|
|
2492
2426
|
);
|
|
2493
2427
|
|
|
2428
|
+
/**
|
|
2429
|
+
* The percentage pill's label. Same number the chip has always shown, written
|
|
2430
|
+
* the way the handoff prints it - `+0%` / `-6.4%`, the sign carrying what the
|
|
2431
|
+
* arrow icon used to. A card without a percentage gets no pill.
|
|
2432
|
+
*/
|
|
2433
|
+
function kpiChipText(card: Record<string, any>): string {
|
|
2434
|
+
const value = Number(card?.percentage);
|
|
2435
|
+
if (!Number.isFinite(value)) return "";
|
|
2436
|
+
return `${value < 0 ? "-" : "+"}${Math.abs(value)}%`;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2494
2439
|
// ─── Dashboard Title ──────────────────────────────────────────────────────────
|
|
2495
2440
|
|
|
2496
2441
|
const dashboardTitle = computed(() => {
|
|
@@ -126,7 +126,18 @@ const { currentUser } = useLocalAuth();
|
|
|
126
126
|
*/
|
|
127
127
|
const { site } = useSite();
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
/**
|
|
130
|
+
* The shared `site` state is only populated by the screens that load a site
|
|
131
|
+
* document, so on most screens - the dashboard included - the bar had no name
|
|
132
|
+
* to print. `SwitchContext` mirrors the label it is ALREADY showing in the
|
|
133
|
+
* rail into this state, so the bar falls back to the very name the user can
|
|
134
|
+
* see two inches to the left. Still no request from this component.
|
|
135
|
+
*/
|
|
136
|
+
const shellSiteName = useState<string>("shell-context-site-name", () => "");
|
|
137
|
+
|
|
138
|
+
const siteName = computed(() =>
|
|
139
|
+
String(site.value?.name || shellSiteName.value || "").trim()
|
|
140
|
+
);
|
|
130
141
|
|
|
131
142
|
const route = useRoute();
|
|
132
143
|
|
|
@@ -188,8 +199,11 @@ const name = computed(() => {
|
|
|
188
199
|
|
|
189
200
|
if(full) return full;
|
|
190
201
|
|
|
202
|
+
/* A member invited by email has no name stored yet, which left the avatar's
|
|
203
|
+
initials empty and the circle blank. The address is what the product shows
|
|
204
|
+
that member everywhere else, so it stands in here too. */
|
|
191
205
|
const alternative = user?.name?.trim();
|
|
192
|
-
return alternative || ""
|
|
206
|
+
return alternative || user?.email?.trim() || ""
|
|
193
207
|
|
|
194
208
|
})
|
|
195
209
|
|
|
@@ -1,68 +1,65 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
<div>
|
|
3
|
+
<!--
|
|
4
|
+
THE DESIGN'S PAGE SCAFFOLD.
|
|
5
|
+
|
|
6
|
+
The handoff opens Members with a 22px title row and puts the tabs INSIDE
|
|
7
|
+
the card's first row, with the pagination on the far end of that same
|
|
8
|
+
line. Before this the screen had no title at all - the page name existed
|
|
9
|
+
only in the rail and the browser tab - and the tabs sat in a toolbar
|
|
10
|
+
extension, i.e. a second stacked bar below a mostly empty one.
|
|
11
|
+
|
|
12
|
+
Nothing here changes what the screen does: the same two tabs route the
|
|
13
|
+
same way, the same refresh and pager call the same `getAll()`, the same
|
|
14
|
+
`headers` prop drives the same columns, and the same menu carries the
|
|
15
|
+
same four actions behind the same permission conditions.
|
|
16
|
+
-->
|
|
17
|
+
<PageHeader :title="props.title">
|
|
18
|
+
<template v-if="props.seatManagement !== 'index'" #actions>
|
|
19
|
+
<AppButton
|
|
9
20
|
:to="{
|
|
10
21
|
name: props.seatManagement,
|
|
11
22
|
params: { organization: props.orgId },
|
|
12
23
|
}"
|
|
13
|
-
size="large"
|
|
14
24
|
>
|
|
15
25
|
Manage seats
|
|
16
|
-
</
|
|
17
|
-
</
|
|
18
|
-
</
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
>
|
|
54
|
-
<v-tab
|
|
55
|
-
v-for="(tab, i) in tabOptions"
|
|
56
|
-
:value="tab.status"
|
|
57
|
-
:key="i"
|
|
58
|
-
class="text-capitalize"
|
|
59
|
-
>
|
|
60
|
-
{{ tab.name }}
|
|
61
|
-
</v-tab>
|
|
62
|
-
</v-tabs>
|
|
63
|
-
</template>
|
|
64
|
-
</v-toolbar>
|
|
65
|
-
|
|
26
|
+
</AppButton>
|
|
27
|
+
</template>
|
|
28
|
+
</PageHeader>
|
|
29
|
+
|
|
30
|
+
<AppCard class="table-card">
|
|
31
|
+
<CardToolbar :count="pageRange">
|
|
32
|
+
<template #tabs>
|
|
33
|
+
<button
|
|
34
|
+
v-for="(tab, i) in tabOptions"
|
|
35
|
+
:key="i"
|
|
36
|
+
type="button"
|
|
37
|
+
role="tab"
|
|
38
|
+
class="app-tab"
|
|
39
|
+
:class="{ 'app-tab--active': selectedStatus === tab.status }"
|
|
40
|
+
:aria-selected="selectedStatus === tab.status"
|
|
41
|
+
@click="toRoute(tab.status)"
|
|
42
|
+
>
|
|
43
|
+
{{ tab.name }}
|
|
44
|
+
</button>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<template #right>
|
|
48
|
+
<AppButton
|
|
49
|
+
variant="icon"
|
|
50
|
+
icon="mdi-refresh"
|
|
51
|
+
aria-label="Refresh"
|
|
52
|
+
@click="getAll()"
|
|
53
|
+
/>
|
|
54
|
+
<local-pagination
|
|
55
|
+
v-model="page"
|
|
56
|
+
:length="pages"
|
|
57
|
+
@update:value="getAll()"
|
|
58
|
+
/>
|
|
59
|
+
</template>
|
|
60
|
+
</CardToolbar>
|
|
61
|
+
|
|
62
|
+
<div class="app-table-scroll">
|
|
66
63
|
<v-data-table
|
|
67
64
|
:headers="props.headers"
|
|
68
65
|
:items="items"
|
|
@@ -76,6 +73,13 @@
|
|
|
76
73
|
<template #item.nature="{ item }">
|
|
77
74
|
{{ replaceMatch(item.nature, "_", " ") }}
|
|
78
75
|
</template>
|
|
76
|
+
<!-- The role is a soft pill in the design, not a plain word. Same
|
|
77
|
+
text, same column, same source field. -->
|
|
78
|
+
<template #item.roleName="{ item }">
|
|
79
|
+
<StatusChip v-if="item.roleName" :dot="false" pill tone="info">
|
|
80
|
+
{{ item.roleName }}
|
|
81
|
+
</StatusChip>
|
|
82
|
+
</template>
|
|
79
83
|
<template #item.dateInvited="{ item }">
|
|
80
84
|
{{ formatDateInvited(item.dateInvited) }}
|
|
81
85
|
</template>
|
|
@@ -92,8 +96,13 @@
|
|
|
92
96
|
offset-y
|
|
93
97
|
width="150"
|
|
94
98
|
>
|
|
95
|
-
<template v-slot:activator="{ props }">
|
|
96
|
-
<
|
|
99
|
+
<template v-slot:activator="{ props: menuProps }">
|
|
100
|
+
<AppButton
|
|
101
|
+
v-bind="menuProps"
|
|
102
|
+
variant="row"
|
|
103
|
+
icon="mdi-dots-vertical"
|
|
104
|
+
aria-label="Member actions"
|
|
105
|
+
/>
|
|
97
106
|
</template>
|
|
98
107
|
<v-list>
|
|
99
108
|
<v-list-item
|
|
@@ -131,8 +140,9 @@
|
|
|
131
140
|
</v-menu>
|
|
132
141
|
</template>
|
|
133
142
|
</v-data-table>
|
|
134
|
-
</
|
|
135
|
-
</
|
|
143
|
+
</div>
|
|
144
|
+
</AppCard>
|
|
145
|
+
|
|
136
146
|
<ConfirmDialog
|
|
137
147
|
v-model="confirmDialog"
|
|
138
148
|
:loading="updateLoading"
|
|
@@ -235,11 +245,17 @@
|
|
|
235
245
|
</v-card-text>
|
|
236
246
|
</v-card>
|
|
237
247
|
</v-dialog>
|
|
238
|
-
</
|
|
248
|
+
</div>
|
|
239
249
|
</template>
|
|
240
250
|
|
|
241
251
|
<script setup lang="ts">
|
|
242
252
|
const props = defineProps({
|
|
253
|
+
/** The design's page title. Every app that mounts this screen calls it
|
|
254
|
+
Members; a caller that names it otherwise passes its own. */
|
|
255
|
+
title: {
|
|
256
|
+
type: String,
|
|
257
|
+
default: "Members",
|
|
258
|
+
},
|
|
243
259
|
orgId: {
|
|
244
260
|
type: String,
|
|
245
261
|
default: "",
|