@7365admin1/layer-common 3.2.1-staging.74 → 3.2.1-staging.75
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,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/layer-common": minor
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Make the Pending tab's invitation actions reachable, and say which state an invitation is really in
|
|
6
|
+
|
|
7
|
+
The row menu in the service-provider list was drawn only for provider rows, so a
|
|
8
|
+
pending invitation offered no actions at all — including the cancel that was
|
|
9
|
+
already wired up behind it. Invitation rows now carry Cancel, Resend and Delete,
|
|
10
|
+
each behind a confirmation that names the company and the site, and Resend is
|
|
11
|
+
disabled with the reason while Seven365 has not approved the invitation yet.
|
|
12
|
+
|
|
13
|
+
Every invitation also used to read "Pending" whatever had happened to it. Rows
|
|
14
|
+
now show the state they are actually in — Waiting for Seven365, Waiting for the
|
|
15
|
+
provider, Not approved, Declined, Cancelled, Expired — in those words, with
|
|
16
|
+
Seven365's reason shown underneath a rejected one.
|
|
17
|
+
|
|
18
|
+
Also here: a new ServiceProviderInvitationPrompt, which is how an existing
|
|
19
|
+
provider answers an invitation from inside the app they already use, with no
|
|
20
|
+
sign-up and no one-time code.
|
|
21
|
+
|
|
22
|
+
Three contrast fixes found while rendering these screens: the list's tab strip
|
|
23
|
+
was a hardcoded light grey that turned into a white slab in dark mode and took
|
|
24
|
+
the tab labels with it; the service type column read the raw stored slug
|
|
25
|
+
("security_agency") instead of "Security"; and `on-warning`, `on-info` and
|
|
26
|
+
dark's `on-error` are now set explicitly, because Vuetify derives white for them
|
|
27
|
+
and that measures 2.37:1, 3.12:1 and 3.60:1 on those fills — every filled
|
|
28
|
+
warning, info and error chip, alert and button in the product.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card
|
|
3
|
+
v-if="invitation"
|
|
4
|
+
width="100%"
|
|
5
|
+
rounded="lg"
|
|
6
|
+
variant="outlined"
|
|
7
|
+
border="thin"
|
|
8
|
+
class="sp-invite-prompt"
|
|
9
|
+
>
|
|
10
|
+
<v-card-item>
|
|
11
|
+
<template #prepend>
|
|
12
|
+
<v-avatar color="primary" variant="tonal" size="44">
|
|
13
|
+
<v-icon>mdi-office-building-marker-outline</v-icon>
|
|
14
|
+
</v-avatar>
|
|
15
|
+
</template>
|
|
16
|
+
<v-card-title class="text-wrap sp-invite-prompt-title">
|
|
17
|
+
{{ invitation.orgName }} has invited you to {{ invitation.siteName }}
|
|
18
|
+
</v-card-title>
|
|
19
|
+
<v-card-subtitle class="text-wrap">
|
|
20
|
+
Approved by Seven365 · {{ expiryLine }}
|
|
21
|
+
</v-card-subtitle>
|
|
22
|
+
</v-card-item>
|
|
23
|
+
|
|
24
|
+
<v-card-text class="text-body-2">
|
|
25
|
+
{{ invitation.orgName }} would like your company to provide services at
|
|
26
|
+
<strong>{{ invitation.siteName }}</strong
|
|
27
|
+
>. If you accept, this site will be added to your list of sites.
|
|
28
|
+
</v-card-text>
|
|
29
|
+
|
|
30
|
+
<v-card-actions class="px-4 pb-4">
|
|
31
|
+
<v-btn
|
|
32
|
+
variant="flat"
|
|
33
|
+
color="primary"
|
|
34
|
+
class="text-none"
|
|
35
|
+
:loading="working === 'accept'"
|
|
36
|
+
:disabled="Boolean(working) || !open"
|
|
37
|
+
@click="accept"
|
|
38
|
+
>
|
|
39
|
+
Accept
|
|
40
|
+
</v-btn>
|
|
41
|
+
<v-btn
|
|
42
|
+
variant="outlined"
|
|
43
|
+
class="text-none ml-2"
|
|
44
|
+
:loading="working === 'decline'"
|
|
45
|
+
:disabled="Boolean(working) || !open"
|
|
46
|
+
@click="decline"
|
|
47
|
+
>
|
|
48
|
+
Decline
|
|
49
|
+
</v-btn>
|
|
50
|
+
<v-spacer />
|
|
51
|
+
<v-chip
|
|
52
|
+
v-if="!open"
|
|
53
|
+
size="small"
|
|
54
|
+
variant="tonal"
|
|
55
|
+
class="text-none"
|
|
56
|
+
:color="invitation.status === 'complete' ? 'success' : undefined"
|
|
57
|
+
>
|
|
58
|
+
{{ invitation.statusLabel }}
|
|
59
|
+
</v-chip>
|
|
60
|
+
</v-card-actions>
|
|
61
|
+
</v-card>
|
|
62
|
+
|
|
63
|
+
<v-card v-else-if="error" width="100%" rounded="lg" variant="outlined" border="thin">
|
|
64
|
+
<v-card-text class="text-body-2">{{ error }}</v-card-text>
|
|
65
|
+
</v-card>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<script setup lang="ts">
|
|
69
|
+
/**
|
|
70
|
+
* The invitation as the service provider sees it — inside the app they already
|
|
71
|
+
* use, from the session they already have.
|
|
72
|
+
*
|
|
73
|
+
* There is deliberately no sign-up and no one-time code anywhere on this
|
|
74
|
+
* screen: a company that already exists on iService365 never creates a second
|
|
75
|
+
* account. The email they receive only carries them here.
|
|
76
|
+
*
|
|
77
|
+
* An invitation that has not been approved by Seven365 is not readable at all —
|
|
78
|
+
* the server answers "not found" — so this component cannot show one.
|
|
79
|
+
*/
|
|
80
|
+
const props = defineProps({
|
|
81
|
+
invitationId: { type: String, required: true },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const emit = defineEmits<{ (e: "answered", status: string): void }>();
|
|
85
|
+
|
|
86
|
+
const { getInvitation, declineInvitation } = useServiceProviderInvitation();
|
|
87
|
+
const { addViaInvite } = useCustomerSite();
|
|
88
|
+
|
|
89
|
+
const invitation = ref<any>(null);
|
|
90
|
+
const error = ref("");
|
|
91
|
+
const working = ref("");
|
|
92
|
+
|
|
93
|
+
const open = computed(() => invitation.value?.status === "pending");
|
|
94
|
+
|
|
95
|
+
const expiryLine = computed(() => {
|
|
96
|
+
const expires = invitation.value?.expireAt;
|
|
97
|
+
if (!expires) return "";
|
|
98
|
+
const days = Math.max(
|
|
99
|
+
0,
|
|
100
|
+
Math.ceil((new Date(expires).getTime() - Date.now()) / 86400000),
|
|
101
|
+
);
|
|
102
|
+
if (!open.value) return "no longer waiting for an answer";
|
|
103
|
+
return days <= 1 ? "expires within a day" : `expires in ${days} days`;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
async function load() {
|
|
107
|
+
try {
|
|
108
|
+
invitation.value = await getInvitation(props.invitationId);
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
error.value =
|
|
111
|
+
err?.data?.message || "This invitation is no longer available.";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function accept() {
|
|
116
|
+
working.value = "accept";
|
|
117
|
+
try {
|
|
118
|
+
await addViaInvite(props.invitationId);
|
|
119
|
+
await load();
|
|
120
|
+
emit("answered", "complete");
|
|
121
|
+
} catch (err: any) {
|
|
122
|
+
error.value = err?.data?.message || "Could not accept this invitation.";
|
|
123
|
+
} finally {
|
|
124
|
+
working.value = "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function decline() {
|
|
129
|
+
working.value = "decline";
|
|
130
|
+
try {
|
|
131
|
+
await declineInvitation(props.invitationId);
|
|
132
|
+
await load();
|
|
133
|
+
emit("answered", "declined");
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
error.value = err?.data?.message || "Could not decline this invitation.";
|
|
136
|
+
} finally {
|
|
137
|
+
working.value = "";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await load();
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<style scoped>
|
|
145
|
+
.sp-invite-prompt-title {
|
|
146
|
+
font-size: 1.05rem;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
line-height: 1.35;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -65,9 +65,12 @@
|
|
|
65
65
|
:loading="loading"
|
|
66
66
|
>
|
|
67
67
|
<div class="sp-table-header">
|
|
68
|
+
<!-- No fixed colour: `grey-lighten-4` is a light-mode grey in both
|
|
69
|
+
themes, which is what made this strip a white slab in dark
|
|
70
|
+
mode. `transparent` lets the themed .sp-table-header show. -->
|
|
68
71
|
<v-toolbar
|
|
69
72
|
density="compact"
|
|
70
|
-
color="
|
|
73
|
+
color="transparent"
|
|
71
74
|
flat
|
|
72
75
|
class="sp-toolbar-top px-2"
|
|
73
76
|
>
|
|
@@ -137,17 +140,68 @@
|
|
|
137
140
|
<span class="text-body-2">{{ item.members }}</span>
|
|
138
141
|
</template>
|
|
139
142
|
<template #item.status="{ item }">
|
|
143
|
+
<!--
|
|
144
|
+
Invitation rows use a FILLED chip, not a tonal one. A tonal chip
|
|
145
|
+
paints the status colour as TEXT, and measured on white the
|
|
146
|
+
warning and info tokens come out at 2.4:1 and 3.1:1 — under the
|
|
147
|
+
4.5:1 a 12px label needs. Filled puts the colour in the
|
|
148
|
+
background and lets Vuetify pick a readable foreground for it.
|
|
149
|
+
-->
|
|
140
150
|
<v-chip
|
|
141
|
-
:color="statusChipColor(item.status)"
|
|
151
|
+
:color="statusChipColor(item.status, item.statusRaw)"
|
|
142
152
|
size="small"
|
|
143
|
-
variant="tonal"
|
|
153
|
+
:variant="item.source === 'invite' ? 'flat' : 'tonal'"
|
|
144
154
|
class="text-none"
|
|
145
155
|
>
|
|
146
156
|
{{ item.status }}
|
|
147
157
|
</v-chip>
|
|
158
|
+
<div
|
|
159
|
+
v-if="item.rejectionReason"
|
|
160
|
+
class="text-caption text-medium-emphasis mt-1 sp-reject-reason"
|
|
161
|
+
>
|
|
162
|
+
Seven365 said: {{ item.rejectionReason }}
|
|
163
|
+
</div>
|
|
148
164
|
</template>
|
|
149
165
|
<template #item.actions="{ item }">
|
|
150
|
-
|
|
166
|
+
<!--
|
|
167
|
+
Invitation rows. The menu used to be drawn only for
|
|
168
|
+
`source === 'provider'`, so a pending invitation showed no
|
|
169
|
+
actions at all and the cancel already wired up behind it was
|
|
170
|
+
unreachable.
|
|
171
|
+
-->
|
|
172
|
+
<v-menu v-if="item.source === 'invite'" location="bottom end">
|
|
173
|
+
<template #activator="{ props: menuProps }">
|
|
174
|
+
<v-btn
|
|
175
|
+
v-bind="menuProps"
|
|
176
|
+
icon
|
|
177
|
+
variant="text"
|
|
178
|
+
density="comfortable"
|
|
179
|
+
aria-label="Invitation actions"
|
|
180
|
+
>
|
|
181
|
+
<v-icon>mdi-dots-vertical</v-icon>
|
|
182
|
+
</v-btn>
|
|
183
|
+
</template>
|
|
184
|
+
<v-list density="compact" min-width="220">
|
|
185
|
+
<v-list-item
|
|
186
|
+
title="Cancel invitation"
|
|
187
|
+
:disabled="!canCancelInvite(item)"
|
|
188
|
+
:subtitle="canCancelInvite(item) ? undefined : 'Only while it is still waiting.'"
|
|
189
|
+
@click="openInviteAction(item, 'cancel')"
|
|
190
|
+
/>
|
|
191
|
+
<v-list-item
|
|
192
|
+
title="Resend invitation"
|
|
193
|
+
:disabled="!canResendInvite(item)"
|
|
194
|
+
:subtitle="resendHint(item)"
|
|
195
|
+
@click="openInviteAction(item, 'resend')"
|
|
196
|
+
/>
|
|
197
|
+
<v-list-item
|
|
198
|
+
title="Delete invitation"
|
|
199
|
+
base-color="error"
|
|
200
|
+
@click="openInviteAction(item, 'delete')"
|
|
201
|
+
/>
|
|
202
|
+
</v-list>
|
|
203
|
+
</v-menu>
|
|
204
|
+
<v-menu v-else-if="item.source === 'provider'" location="bottom end">
|
|
151
205
|
<template #activator="{ props: menuProps }">
|
|
152
206
|
<v-btn
|
|
153
207
|
v-bind="menuProps"
|
|
@@ -284,6 +338,56 @@
|
|
|
284
338
|
</v-card>
|
|
285
339
|
</v-dialog>
|
|
286
340
|
|
|
341
|
+
<!-- Cancel / Resend / Delete an invitation. Same shape as the provider
|
|
342
|
+
dialog above so the two read as one product. -->
|
|
343
|
+
<v-dialog v-model="inviteDialog" max-width="728" persistent>
|
|
344
|
+
<v-card rounded="lg" class="sp-status-card">
|
|
345
|
+
<v-card-title class="sp-status-title">
|
|
346
|
+
{{ inviteDialogTitle }}
|
|
347
|
+
</v-card-title>
|
|
348
|
+
|
|
349
|
+
<v-card-text class="sp-status-body">
|
|
350
|
+
<div class="sp-status-question">
|
|
351
|
+
{{ inviteDialogQuestion }}
|
|
352
|
+
</div>
|
|
353
|
+
<div class="sp-status-copy">
|
|
354
|
+
{{ inviteDialogCopy }}
|
|
355
|
+
</div>
|
|
356
|
+
</v-card-text>
|
|
357
|
+
|
|
358
|
+
<v-divider />
|
|
359
|
+
|
|
360
|
+
<v-card-actions class="pa-0">
|
|
361
|
+
<v-row no-gutters class="fill-height">
|
|
362
|
+
<v-col cols="6">
|
|
363
|
+
<v-btn
|
|
364
|
+
block
|
|
365
|
+
variant="flat"
|
|
366
|
+
height="82"
|
|
367
|
+
class="text-none sp-status-cancel"
|
|
368
|
+
:disabled="inviteWorking"
|
|
369
|
+
@click="closeInviteDialog"
|
|
370
|
+
>
|
|
371
|
+
Keep it
|
|
372
|
+
</v-btn>
|
|
373
|
+
</v-col>
|
|
374
|
+
<v-col cols="6">
|
|
375
|
+
<v-btn
|
|
376
|
+
block
|
|
377
|
+
variant="flat"
|
|
378
|
+
height="82"
|
|
379
|
+
class="text-none sp-status-confirm"
|
|
380
|
+
:loading="inviteWorking"
|
|
381
|
+
@click="confirmInviteAction"
|
|
382
|
+
>
|
|
383
|
+
{{ inviteConfirmLabel }}
|
|
384
|
+
</v-btn>
|
|
385
|
+
</v-col>
|
|
386
|
+
</v-row>
|
|
387
|
+
</v-card-actions>
|
|
388
|
+
</v-card>
|
|
389
|
+
</v-dialog>
|
|
390
|
+
|
|
287
391
|
<v-dialog v-model="createDialog" width="500" persistent>
|
|
288
392
|
<v-card width="100%">
|
|
289
393
|
<v-toolbar>
|
|
@@ -582,6 +686,11 @@ type TableRow = {
|
|
|
582
686
|
members: string;
|
|
583
687
|
status: string;
|
|
584
688
|
source: "provider" | "invite";
|
|
689
|
+
/** the stored status, for deciding which actions a row may offer */
|
|
690
|
+
statusRaw?: string;
|
|
691
|
+
/** shown under a "Not approved" row, in Seven365's own words */
|
|
692
|
+
rejectionReason?: string;
|
|
693
|
+
siteName?: string;
|
|
585
694
|
};
|
|
586
695
|
|
|
587
696
|
type ServiceProviderMemberRow = {
|
|
@@ -739,6 +848,13 @@ const { getAll: getAllMembers, getAllByUserId } = useMember();
|
|
|
739
848
|
const { getVerifications, cancelUserInvitation, getVerificationByEmail } =
|
|
740
849
|
useVerification();
|
|
741
850
|
|
|
851
|
+
const {
|
|
852
|
+
getInvitations,
|
|
853
|
+
cancelInvitation,
|
|
854
|
+
resendInvitation,
|
|
855
|
+
deleteInvitation,
|
|
856
|
+
} = useServiceProviderInvitation();
|
|
857
|
+
|
|
742
858
|
const existingAccount = ref<any>(null);
|
|
743
859
|
const checkingExistingAccount = ref(false);
|
|
744
860
|
// const { getRoles } = useRole();
|
|
@@ -877,6 +993,109 @@ function canViewMembers(row: TableRow) {
|
|
|
877
993
|
return row.source === "provider";
|
|
878
994
|
}
|
|
879
995
|
|
|
996
|
+
// --- invitation actions (Cancel / Resend / Delete) ---------------------------
|
|
997
|
+
//
|
|
998
|
+
// Each one confirms first, and each one leaves an audit line on the server —
|
|
999
|
+
// who did it, when, and what. Delete is a SOFT delete: the row leaves this
|
|
1000
|
+
// list, Seven365 keeps the record and its history.
|
|
1001
|
+
|
|
1002
|
+
type InviteAction = "cancel" | "resend" | "delete";
|
|
1003
|
+
|
|
1004
|
+
const inviteDialog = ref(false);
|
|
1005
|
+
const inviteWorking = ref(false);
|
|
1006
|
+
const inviteActionRow = ref<TableRow | null>(null);
|
|
1007
|
+
const inviteAction = ref<InviteAction>("cancel");
|
|
1008
|
+
|
|
1009
|
+
function canCancelInvite(row: TableRow) {
|
|
1010
|
+
return ["awaiting-approval", "pending"].includes(row.statusRaw ?? "");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function canResendInvite(row: TableRow) {
|
|
1014
|
+
return (row.statusRaw ?? "") === "pending";
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function resendHint(row: TableRow) {
|
|
1018
|
+
if (canResendInvite(row)) return undefined;
|
|
1019
|
+
return (row.statusRaw ?? "") === "awaiting-approval"
|
|
1020
|
+
? "You can resend once Seven365 has approved it."
|
|
1021
|
+
: "Only an approved invitation can be sent again.";
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const inviteDialogTitle = computed(() => {
|
|
1025
|
+
if (inviteAction.value === "resend") return "Resend Invitation";
|
|
1026
|
+
if (inviteAction.value === "delete") return "Delete Invitation";
|
|
1027
|
+
return "Cancel Invitation";
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const inviteDialogQuestion = computed(() => {
|
|
1031
|
+
const row = inviteActionRow.value;
|
|
1032
|
+
const who = row?.companyName && row.companyName !== "-" ? row.companyName : row?.email;
|
|
1033
|
+
const site = row?.siteName ? ` for ${row.siteName}` : "";
|
|
1034
|
+
|
|
1035
|
+
if (inviteAction.value === "resend") {
|
|
1036
|
+
return `Send this invitation again to ${row?.email}?`;
|
|
1037
|
+
}
|
|
1038
|
+
if (inviteAction.value === "delete") {
|
|
1039
|
+
return "Delete this invitation permanently?";
|
|
1040
|
+
}
|
|
1041
|
+
return `Cancel this invitation to ${who}${site}?`;
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const inviteDialogCopy = computed(() => {
|
|
1045
|
+
if (inviteAction.value === "resend") {
|
|
1046
|
+
return "They will get a fresh message and another 3 days to accept. This does not create a second invitation.";
|
|
1047
|
+
}
|
|
1048
|
+
if (inviteAction.value === "delete") {
|
|
1049
|
+
return "It will disappear from this list. Seven365 keeps the record of who sent it and what happened to it.";
|
|
1050
|
+
}
|
|
1051
|
+
return "They will not be able to accept it. You can send a new one later.";
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const inviteConfirmLabel = computed(() => {
|
|
1055
|
+
if (inviteAction.value === "resend") return "Resend";
|
|
1056
|
+
if (inviteAction.value === "delete") return "Delete";
|
|
1057
|
+
return "Cancel invitation";
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
function openInviteAction(row: TableRow, action: InviteAction) {
|
|
1061
|
+
if (action === "cancel" && !canCancelInvite(row)) return;
|
|
1062
|
+
if (action === "resend" && !canResendInvite(row)) return;
|
|
1063
|
+
inviteActionRow.value = row;
|
|
1064
|
+
inviteAction.value = action;
|
|
1065
|
+
inviteDialog.value = true;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function closeInviteDialog() {
|
|
1069
|
+
if (inviteWorking.value) return;
|
|
1070
|
+
inviteDialog.value = false;
|
|
1071
|
+
inviteActionRow.value = null;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function confirmInviteAction() {
|
|
1075
|
+
const row = inviteActionRow.value;
|
|
1076
|
+
if (!row) return;
|
|
1077
|
+
|
|
1078
|
+
inviteWorking.value = true;
|
|
1079
|
+
try {
|
|
1080
|
+
const run =
|
|
1081
|
+
inviteAction.value === "resend"
|
|
1082
|
+
? resendInvitation
|
|
1083
|
+
: inviteAction.value === "delete"
|
|
1084
|
+
? deleteInvitation
|
|
1085
|
+
: cancelInvitation;
|
|
1086
|
+
|
|
1087
|
+
const result = await run(row._id);
|
|
1088
|
+
showMessage(result?.message ?? "Done.", "success");
|
|
1089
|
+
inviteDialog.value = false;
|
|
1090
|
+
inviteActionRow.value = null;
|
|
1091
|
+
await loadList();
|
|
1092
|
+
} catch (error: any) {
|
|
1093
|
+
showMessage(errorConverter(error), "error");
|
|
1094
|
+
} finally {
|
|
1095
|
+
inviteWorking.value = false;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
880
1099
|
const statusDialog = ref(false);
|
|
881
1100
|
const statusUpdating = ref(false);
|
|
882
1101
|
const statusActionRow = ref<TableRow | null>(null);
|
|
@@ -908,7 +1127,11 @@ function rowActionLabel(row: TableRow) {
|
|
|
908
1127
|
|
|
909
1128
|
function typeDisplay(type: string, nature?: string) {
|
|
910
1129
|
if (nature && String(nature).trim()) return String(nature);
|
|
911
|
-
|
|
1130
|
+
// useLocal returns a plain array; reading `.value` off it was always
|
|
1131
|
+
// undefined, so every row fell through to the raw stored slug
|
|
1132
|
+
// ("security_agency") instead of "Security".
|
|
1133
|
+
const source = natureOfBusinessV3 as any;
|
|
1134
|
+
const list = (Array.isArray(source) ? source : source?.value) as Array<{
|
|
912
1135
|
title?: string;
|
|
913
1136
|
value?: string;
|
|
914
1137
|
}>;
|
|
@@ -924,7 +1147,18 @@ function statusDisplay(raw: string, source: TableRow["source"]) {
|
|
|
924
1147
|
return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
|
|
925
1148
|
}
|
|
926
1149
|
|
|
927
|
-
|
|
1150
|
+
const INVITE_STATUS_COLOURS: Record<string, string> = {
|
|
1151
|
+
"awaiting-approval": "warning",
|
|
1152
|
+
pending: "info",
|
|
1153
|
+
complete: "success",
|
|
1154
|
+
rejected: "error",
|
|
1155
|
+
declined: "default",
|
|
1156
|
+
cancelled: "default",
|
|
1157
|
+
expired: "default",
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
function statusChipColor(statusLabel: string, statusRaw?: string) {
|
|
1161
|
+
if (statusRaw) return INVITE_STATUS_COLOURS[statusRaw] ?? "primary";
|
|
928
1162
|
const s = statusLabel.toLowerCase();
|
|
929
1163
|
if (s === "active") return "success";
|
|
930
1164
|
if (s === "pending") return "warning";
|
|
@@ -1006,7 +1240,12 @@ function mapInviteRows(raw: Record<string, any>[]): TableRow[] {
|
|
|
1006
1240
|
? typeDisplay(row.metadata.app)
|
|
1007
1241
|
: inviteTypeLabel(row.type ?? ""),
|
|
1008
1242
|
members: "-",
|
|
1009
|
-
|
|
1243
|
+
// Every invitation used to read "Pending" whatever had happened to it. It
|
|
1244
|
+
// now says which of the seven states it is actually in, in words.
|
|
1245
|
+
status: row.statusLabel || serviceProviderInviteLabel(row.status),
|
|
1246
|
+
statusRaw: String(row.status ?? ""),
|
|
1247
|
+
rejectionReason: row.rejectionReason ?? "",
|
|
1248
|
+
siteName: row.metadata?.siteName ?? "",
|
|
1010
1249
|
source: "invite",
|
|
1011
1250
|
}));
|
|
1012
1251
|
}
|
|
@@ -1022,13 +1261,14 @@ async function loadList() {
|
|
|
1022
1261
|
loading.value = true;
|
|
1023
1262
|
try {
|
|
1024
1263
|
if (listTab.value === "pending") {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1264
|
+
// Everything that is not a finished engagement, so a rejection or a
|
|
1265
|
+
// cancellation is visible here instead of the row simply vanishing.
|
|
1266
|
+
const data = await getInvitations({
|
|
1267
|
+
org: props.orgId,
|
|
1268
|
+
status:
|
|
1269
|
+
"awaiting-approval,pending,rejected,declined,cancelled,expired",
|
|
1028
1270
|
page: page.value,
|
|
1029
1271
|
search: searchText.value?.trim() || "",
|
|
1030
|
-
siteId: props.siteId,
|
|
1031
|
-
orgId: props.orgId,
|
|
1032
1272
|
limit: pageSize,
|
|
1033
1273
|
});
|
|
1034
1274
|
items.value = mapInviteRows(data.items ?? []);
|
|
@@ -1282,8 +1522,12 @@ async function submitServiceProviderInvite() {
|
|
|
1282
1522
|
}
|
|
1283
1523
|
|
|
1284
1524
|
.sp-table-header {
|
|
1285
|
-
|
|
1286
|
-
|
|
1525
|
+
/* Was a hardcoded #f5f5f5 with a black border: in dark mode the tab strip
|
|
1526
|
+
became a white slab and the tab labels and the page range disappeared
|
|
1527
|
+
into it. These take the colour of whichever theme they are in. */
|
|
1528
|
+
background: rgb(var(--v-theme-surface-light, var(--v-theme-surface)));
|
|
1529
|
+
border-bottom: 1px solid
|
|
1530
|
+
rgba(var(--v-border-color), var(--v-border-opacity));
|
|
1287
1531
|
}
|
|
1288
1532
|
|
|
1289
1533
|
.sp-tabs {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-provider invitations — the list a property manager sees and the
|
|
3
|
+
* things they can do to a row.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `useVerification` because these rows are not generic
|
|
6
|
+
* verifications any more: they have their own states (waiting for Seven365,
|
|
7
|
+
* waiting for the provider, not approved, declined) and their own actions,
|
|
8
|
+
* and the server scopes them to the caller's own organisation.
|
|
9
|
+
*/
|
|
10
|
+
export type TServiceProviderInvitation = {
|
|
11
|
+
_id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
type: string;
|
|
14
|
+
status: string;
|
|
15
|
+
statusLabel: string;
|
|
16
|
+
rejectionReason?: string | null;
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
expireAt?: string;
|
|
19
|
+
deletedAt?: string | null;
|
|
20
|
+
metadata?: Record<string, any>;
|
|
21
|
+
history?: Array<{
|
|
22
|
+
action: string;
|
|
23
|
+
at: string;
|
|
24
|
+
byName?: string;
|
|
25
|
+
reason?: string;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The words on screen. Kept beside the server's own list on purpose — a
|
|
30
|
+
* status the server invents must never render as a raw stored value. */
|
|
31
|
+
export const SERVICE_PROVIDER_INVITE_LABELS: Record<string, string> = {
|
|
32
|
+
"awaiting-approval": "Waiting for Seven365",
|
|
33
|
+
pending: "Waiting for the provider",
|
|
34
|
+
rejected: "Not approved",
|
|
35
|
+
complete: "Accepted",
|
|
36
|
+
declined: "Declined",
|
|
37
|
+
cancelled: "Cancelled",
|
|
38
|
+
expired: "Expired",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function serviceProviderInviteLabel(status?: string) {
|
|
42
|
+
return SERVICE_PROVIDER_INVITE_LABELS[String(status ?? "")] ?? "Unknown";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function useServiceProviderInvitation() {
|
|
46
|
+
const base = "/api/service-provider-invitations";
|
|
47
|
+
|
|
48
|
+
function getInvitations({
|
|
49
|
+
org = "",
|
|
50
|
+
status = "",
|
|
51
|
+
page = 1,
|
|
52
|
+
limit = 20,
|
|
53
|
+
} = {}): Promise<{
|
|
54
|
+
items: TServiceProviderInvitation[];
|
|
55
|
+
pages: number;
|
|
56
|
+
pageRange: string;
|
|
57
|
+
}> {
|
|
58
|
+
return useNuxtApp().$api(base, {
|
|
59
|
+
method: "GET",
|
|
60
|
+
query: { org, status, page, limit },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cancelInvitation(id: string) {
|
|
65
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/cancel`, {
|
|
66
|
+
method: "PUT",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Sends the same invitation again. Never creates a second row. */
|
|
71
|
+
function resendInvitation(id: string) {
|
|
72
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/resend`, {
|
|
73
|
+
method: "PUT",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Soft delete — the row leaves this list, Seven365 keeps the record. */
|
|
78
|
+
function deleteInvitation(id: string) {
|
|
79
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}`, {
|
|
80
|
+
method: "DELETE",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- Seven365 super admin ------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function getApprovals({
|
|
87
|
+
status = "awaiting-approval",
|
|
88
|
+
page = 1,
|
|
89
|
+
limit = 20,
|
|
90
|
+
includeDeleted = false,
|
|
91
|
+
} = {}): Promise<{
|
|
92
|
+
items: TServiceProviderInvitation[];
|
|
93
|
+
pages: number;
|
|
94
|
+
pageRange: string;
|
|
95
|
+
awaitingApprovalCount: number;
|
|
96
|
+
}> {
|
|
97
|
+
return useNuxtApp().$api(`${base}/approvals`, {
|
|
98
|
+
method: "GET",
|
|
99
|
+
query: { status, page, limit, includeDeleted },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getApprovalCount(): Promise<{ count: number }> {
|
|
104
|
+
return useNuxtApp().$api(`${base}/approvals/count`, { method: "GET" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function approveInvitation(id: string) {
|
|
108
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/approve`, {
|
|
109
|
+
method: "PUT",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The reason is required — it is what the property manager is shown. */
|
|
114
|
+
function rejectInvitation(id: string, reason: string) {
|
|
115
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/reject`, {
|
|
116
|
+
method: "PUT",
|
|
117
|
+
body: { reason },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- the invited service provider ----------------------------------------
|
|
122
|
+
|
|
123
|
+
function getInvitation(id: string): Promise<TServiceProviderInvitation> {
|
|
124
|
+
return useNuxtApp().$api(`${base}/${id}`, { method: "GET" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function declineInvitation(id: string) {
|
|
128
|
+
return useNuxtApp().$api<{ message: string }>(`${base}/${id}/decline`, {
|
|
129
|
+
method: "PUT",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
getInvitations,
|
|
135
|
+
cancelInvitation,
|
|
136
|
+
resendInvitation,
|
|
137
|
+
deleteInvitation,
|
|
138
|
+
getApprovals,
|
|
139
|
+
getApprovalCount,
|
|
140
|
+
approveInvitation,
|
|
141
|
+
rejectInvitation,
|
|
142
|
+
getInvitation,
|
|
143
|
+
declineInvitation,
|
|
144
|
+
};
|
|
145
|
+
}
|
package/package.json
CHANGED
package/utils/theme.ts
CHANGED
|
@@ -75,6 +75,17 @@ export const LIGHT_THEME = {
|
|
|
75
75
|
"brand-surface": "#EDF1F7",
|
|
76
76
|
/** The drawer's label colour. On the rail: 15.43:1. */
|
|
77
77
|
"on-brand-surface": "#0B1B27",
|
|
78
|
+
/**
|
|
79
|
+
* Vuetify leaves `warning` (#FB8C00) and `info` (#2196F3) at its stock
|
|
80
|
+
* values and DERIVES their foreground, and — as with `on-primary` — the
|
|
81
|
+
* derivation picks WHITE. Measured, that is 2.37:1 on warning and 3.12:1
|
|
82
|
+
* on info: every filled warning/info chip, alert, button and badge in the
|
|
83
|
+
* product, including the invitation status chips. Setting the label to the
|
|
84
|
+
* dark page colour takes them to 8.9:1 and 5.3:1. Same value in both
|
|
85
|
+
* themes because the two fills are the same in both themes.
|
|
86
|
+
*/
|
|
87
|
+
"on-warning": "#0E1319",
|
|
88
|
+
"on-info": "#0E1319",
|
|
78
89
|
"primary-button": "#1867C0",
|
|
79
90
|
"text-primary": "#052439",
|
|
80
91
|
},
|
|
@@ -132,6 +143,14 @@ export const DARK_THEME = {
|
|
|
132
143
|
"brand-surface": "#1B242F",
|
|
133
144
|
/** The drawer's label colour. On the rail: 13.21:1. */
|
|
134
145
|
"on-brand-surface": "#E8ECF1",
|
|
146
|
+
/** See LIGHT_THEME: Vuetify derives white on these fills, at 2.37:1. */
|
|
147
|
+
"on-warning": "#0E1319",
|
|
148
|
+
"on-info": "#0E1319",
|
|
149
|
+
/**
|
|
150
|
+
* Dark's stock `error` is the pale #CF6679, and white on it is 3.60:1 —
|
|
151
|
+
* the light theme's darker #B00020 carries white fine, this one does not.
|
|
152
|
+
*/
|
|
153
|
+
"on-error": "#0E1319",
|
|
135
154
|
/** Unchanged on purpose: it already passes in both themes. */
|
|
136
155
|
"primary-button": "#1867C0",
|
|
137
156
|
/** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
|