@antelopejs/dms-saas 0.2.0 → 0.2.1
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/README.md +257 -10
- package/dist/billing-state/index.d.ts +1 -0
- package/dist/billing-state/index.js +1 -0
- package/dist/billing-state/index.js.map +1 -1
- package/dist/billing-state/past-due-banner.d.ts +4 -0
- package/dist/billing-state/past-due-banner.js +25 -0
- package/dist/billing-state/past-due-banner.js.map +1 -0
- package/dist/config/runtime.d.ts +6 -1
- package/dist/config/runtime.js +50 -1
- package/dist/config/runtime.js.map +1 -1
- package/dist/crons/recompute-billing-state.js +4 -1
- package/dist/crons/recompute-billing-state.js.map +1 -1
- package/dist/data-api/platformOwner/index.d.ts +1 -0
- package/dist/data-api/platformOwner/index.js +1 -0
- package/dist/data-api/platformOwner/index.js.map +1 -1
- package/dist/data-api/platformOwner/invitations.d.ts +22 -0
- package/dist/data-api/platformOwner/invitations.js +121 -0
- package/dist/data-api/platformOwner/invitations.js.map +1 -0
- package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
- package/dist/data-api/platformOwner/workspaces.js +64 -12
- package/dist/data-api/platformOwner/workspaces.js.map +1 -1
- package/dist/data-api/tenant/tenant-invoices.js +2 -10
- package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
- package/dist/hooks/register.js +2 -1
- package/dist/hooks/register.js.map +1 -1
- package/dist/implementations/dms-saas/billing.d.ts +2 -1
- package/dist/implementations/dms-saas/billing.js +6 -0
- package/dist/implementations/dms-saas/billing.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/invoice-line-items/injection.d.ts +10 -0
- package/dist/invoice-line-items/injection.js +7 -3
- package/dist/invoice-line-items/injection.js.map +1 -1
- package/dist/operator-actions/index.d.ts +1 -0
- package/dist/operator-actions/index.js +1 -0
- package/dist/operator-actions/index.js.map +1 -1
- package/dist/operator-actions/invitation-commands.d.ts +12 -0
- package/dist/operator-actions/invitation-commands.js +46 -0
- package/dist/operator-actions/invitation-commands.js.map +1 -0
- package/dist/operator-actions/types.d.ts +1 -1
- package/dist/pages/platform/dashboard.js +2 -4
- package/dist/pages/platform/dashboard.js.map +1 -1
- package/dist/pages/platform/workspaces/detail.js +40 -1
- package/dist/pages/platform/workspaces/detail.js.map +1 -1
- package/dist/pages/platform/workspaces/index.d.ts +2 -8
- package/dist/pages/platform/workspaces/index.js +27 -111
- package/dist/pages/platform/workspaces/index.js.map +1 -1
- package/dist/pages/public/legal/privacy-policy.js +2 -1
- package/dist/pages/public/legal/privacy-policy.js.map +1 -1
- package/dist/pages/public/legal/terms-and-conditions.js +2 -1
- package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
- package/dist/pages/public/legal/terms-of-use.js +2 -1
- package/dist/pages/public/legal/terms-of-use.js.map +1 -1
- package/dist/plans/index.d.ts +1 -0
- package/dist/plans/index.js +1 -0
- package/dist/plans/index.js.map +1 -1
- package/dist/plans/platform-support.d.ts +2 -0
- package/dist/plans/platform-support.js +19 -0
- package/dist/plans/platform-support.js.map +1 -0
- package/dist/plans/seat-capacity.d.ts +9 -2
- package/dist/plans/seat-capacity.js +59 -12
- package/dist/plans/seat-capacity.js.map +1 -1
- package/dist/plans/seat-hooks.js +34 -13
- package/dist/plans/seat-hooks.js.map +1 -1
- package/dist/routes/platformOwner/dashboard.d.ts +8 -6
- package/dist/routes/platformOwner/dashboard.js +31 -17
- package/dist/routes/platformOwner/dashboard.js.map +1 -1
- package/dist/routes/platformOwner/index.d.ts +1 -0
- package/dist/routes/platformOwner/index.js +1 -0
- package/dist/routes/platformOwner/index.js.map +1 -1
- package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
- package/dist/routes/platformOwner/workspace-invitations.js +73 -0
- package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
- package/dist/routes/public/register.d.ts +7 -3
- package/dist/routes/public/register.js +42 -27
- package/dist/routes/public/register.js.map +1 -1
- package/dist/routes/tenant/tenant-billing.d.ts +7 -11
- package/dist/routes/tenant/tenant-billing.js +34 -26
- package/dist/routes/tenant/tenant-billing.js.map +1 -1
- package/dist/routes/tenant/tenant-plan.d.ts +1 -0
- package/dist/routes/tenant/tenant-plan.js +27 -2
- package/dist/routes/tenant/tenant-plan.js.map +1 -1
- package/dist/routes/tenant/tenant-seats.d.ts +2 -4
- package/dist/routes/tenant/tenant-seats.js.map +1 -1
- package/dist/routes/tenant/workspaces.d.ts +2 -0
- package/dist/routes/tenant/workspaces.js +10 -4
- package/dist/routes/tenant/workspaces.js.map +1 -1
- package/dist/stripe/upcoming-invoice.d.ts +21 -0
- package/dist/stripe/upcoming-invoice.js +42 -0
- package/dist/stripe/upcoming-invoice.js.map +1 -0
- package/dist/stripe/webhook-dispatch.js +2 -0
- package/dist/stripe/webhook-dispatch.js.map +1 -1
- package/dist/types/config.d.ts +9 -0
- package/dist/types/config.js +7 -0
- package/dist/types/config.js.map +1 -1
- package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
- package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
- package/dist/upcoming-invoice/eligibility.d.ts +17 -0
- package/dist/upcoming-invoice/eligibility.js +52 -0
- package/dist/upcoming-invoice/eligibility.js.map +1 -0
- package/dist/upcoming-invoice/invalidation.d.ts +2 -0
- package/dist/upcoming-invoice/invalidation.js +39 -0
- package/dist/upcoming-invoice/invalidation.js.map +1 -0
- package/dist/upcoming-invoice/mapping.d.ts +8 -0
- package/dist/upcoming-invoice/mapping.js +82 -0
- package/dist/upcoming-invoice/mapping.js.map +1 -0
- package/dist/upcoming-invoice/outcomes.d.ts +13 -0
- package/dist/upcoming-invoice/outcomes.js +15 -0
- package/dist/upcoming-invoice/outcomes.js.map +1 -0
- package/dist/upcoming-invoice/preview.d.ts +3 -0
- package/dist/upcoming-invoice/preview.js +80 -0
- package/dist/upcoming-invoice/preview.js.map +1 -0
- package/dist/upcoming-invoice/pricing.d.ts +3 -0
- package/dist/upcoming-invoice/pricing.js +84 -0
- package/dist/upcoming-invoice/pricing.js.map +1 -0
- package/dist/upcoming-invoice/usage.d.ts +14 -0
- package/dist/upcoming-invoice/usage.js +53 -0
- package/dist/upcoming-invoice/usage.js.map +1 -0
- package/dist/workspaces/billing-identity.d.ts +19 -0
- package/dist/workspaces/billing-identity.js +51 -0
- package/dist/workspaces/billing-identity.js.map +1 -0
- package/dist/workspaces/default-plan.d.ts +5 -0
- package/dist/workspaces/default-plan.js +109 -0
- package/dist/workspaces/default-plan.js.map +1 -0
- package/dist/workspaces/index.d.ts +2 -0
- package/dist/workspaces/index.js +2 -0
- package/dist/workspaces/index.js.map +1 -1
- package/dist/workspaces/invitations.d.ts +26 -0
- package/dist/workspaces/invitations.js +112 -0
- package/dist/workspaces/invitations.js.map +1 -0
- package/dist/workspaces/provisioning.d.ts +3 -3
- package/dist/workspaces/provisioning.js +39 -11
- package/dist/workspaces/provisioning.js.map +1 -1
- package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
- package/frontend-vue/app/components/PastDueBanner.vue +81 -0
- package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
- package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
- package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
- package/frontend-vue/app/components/PlatformNotes.vue +4 -0
- package/frontend-vue/app/components/RefundButton.vue +10 -1
- package/frontend-vue/app/components/Register.vue +49 -92
- package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
- package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
- package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
- package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
- package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
- package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
- package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
- package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
- package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
- package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
- package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
- package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
- package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
- package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
- package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
- package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
- package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
- package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
- package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
- package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
- package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
- package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
- package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
- package/frontend-vue/app/plugins/auth-links.ts +0 -4
- package/frontend-vue/app/types/runtime-config.ts +13 -0
- package/frontend-vue/dms.frontend.ts +10 -4
- package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
- package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
- package/frontend-vue/package.json +0 -1
- package/frontend-vue/pnpm-lock.yaml +1 -125
- package/package.json +5 -5
- package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
|
@@ -1,79 +1,43 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref } from "vue";
|
|
3
3
|
|
|
4
|
-
interface BillingAddress {
|
|
5
|
-
line1: string | null;
|
|
6
|
-
line2: string | null;
|
|
7
|
-
postalCode: string | null;
|
|
8
|
-
city: string | null;
|
|
9
|
-
state: string | null;
|
|
10
|
-
country: string | null;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type VatVerificationStatus = "pending" | "verified" | "unverified";
|
|
14
|
-
|
|
15
|
-
interface BillingInfoResponse {
|
|
16
|
-
customerType: "individual" | "business" | null;
|
|
17
|
-
companyName: string | null;
|
|
18
|
-
vatNumber: string | null;
|
|
19
|
-
vatVerificationStatus: VatVerificationStatus | null;
|
|
20
|
-
billingEmail: string | null;
|
|
21
|
-
address: BillingAddress | null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const VAT_BADGE_COLORS: Record<VatVerificationStatus, "success" | "warning"> = {
|
|
25
|
-
verified: "success",
|
|
26
|
-
pending: "warning",
|
|
27
|
-
unverified: "warning",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const BILLING_INFO_ENDPOINT = "/api/saas/tenant/billing-info";
|
|
31
|
-
|
|
32
|
-
const { $authFetch } = useAuthFetch();
|
|
33
|
-
const nuxtApp = useDmsApp();
|
|
34
4
|
const toast = useToast();
|
|
5
|
+
const { t } = useI18n();
|
|
35
6
|
const { resolveApiError } = useApiErrorMessage();
|
|
36
|
-
const
|
|
7
|
+
const identity = useBillingIdentity();
|
|
37
8
|
const { data: billingStatus, load: loadBillingStatus } = useBillingStatus();
|
|
38
9
|
|
|
39
10
|
/** Saving is owner-only server-side; a member gets the form read-only
|
|
40
11
|
* instead of a save button that can only end in a 403 toast. */
|
|
41
12
|
const isTenantOwner = computed(() => !!billingStatus.value?.isTenantOwner);
|
|
42
13
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const vatNumber = ref("");
|
|
14
|
+
const draft = ref<BillingIdentityDraft>(emptyBillingIdentityDraft());
|
|
15
|
+
const savedMissingFields = ref<BillingIdentityField[]>([]);
|
|
46
16
|
const vatVerificationStatus = ref<VatVerificationStatus | null>(null);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
17
|
+
const hasTriedSubmit = ref(false);
|
|
18
|
+
// Flagged once a submit was attempted, then re-checked as the user types so
|
|
19
|
+
// a corrected field clears its error right away.
|
|
20
|
+
const invalidFields = computed<BillingIdentityField[]>(() =>
|
|
21
|
+
hasTriedSubmit.value ? findMissingBillingFields(draft.value) : [],
|
|
22
|
+
);
|
|
52
23
|
const isLoading = ref(true);
|
|
53
24
|
const loadFailed = ref(false);
|
|
54
25
|
const isSaving = ref(false);
|
|
55
26
|
|
|
56
|
-
|
|
27
|
+
/** The badge reflects what is stored, not what is being typed. */
|
|
28
|
+
const isIncomplete = computed(() => savedMissingFields.value.length > 0);
|
|
57
29
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
30
|
+
function apply(info: BillingInfoResponse): void {
|
|
31
|
+
draft.value = toBillingIdentityDraft(info);
|
|
32
|
+
savedMissingFields.value = info.missingFields;
|
|
33
|
+
vatVerificationStatus.value = info.vatVerificationStatus;
|
|
34
|
+
}
|
|
62
35
|
|
|
63
36
|
async function load(): Promise<void> {
|
|
64
37
|
isLoading.value = true;
|
|
65
38
|
loadFailed.value = false;
|
|
66
39
|
try {
|
|
67
|
-
|
|
68
|
-
customerType.value = info.customerType;
|
|
69
|
-
companyName.value = info.companyName ?? "";
|
|
70
|
-
vatNumber.value = info.vatNumber ?? "";
|
|
71
|
-
vatVerificationStatus.value = info.vatVerificationStatus;
|
|
72
|
-
billingEmail.value = info.billingEmail ?? "";
|
|
73
|
-
country.value = info.address?.country ?? "";
|
|
74
|
-
addressLine1.value = info.address?.line1 ?? "";
|
|
75
|
-
postalCode.value = info.address?.postalCode ?? "";
|
|
76
|
-
city.value = info.address?.city ?? "";
|
|
40
|
+
apply(await identity.load());
|
|
77
41
|
} catch {
|
|
78
42
|
loadFailed.value = true;
|
|
79
43
|
} finally {
|
|
@@ -82,29 +46,20 @@ async function load(): Promise<void> {
|
|
|
82
46
|
}
|
|
83
47
|
|
|
84
48
|
async function save(): Promise<void> {
|
|
49
|
+
hasTriedSubmit.value = true;
|
|
50
|
+
if (invalidFields.value.length > 0) {
|
|
51
|
+
toast.add({
|
|
52
|
+
title: t("saas.workspace.billing.identity.incomplete_toast"),
|
|
53
|
+
color: "error",
|
|
54
|
+
icon: "i-ph-warning-circle",
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
85
58
|
isSaving.value = true;
|
|
86
59
|
try {
|
|
87
|
-
|
|
88
|
-
BILLING_INFO_ENDPOINT,
|
|
89
|
-
{
|
|
90
|
-
method: "PUT",
|
|
91
|
-
body: {
|
|
92
|
-
companyName: isBusiness.value ? companyName.value : undefined,
|
|
93
|
-
vatNumber: isBusiness.value ? vatNumber.value : undefined,
|
|
94
|
-
billingEmail: billingEmail.value || null,
|
|
95
|
-
address: {
|
|
96
|
-
country: country.value || undefined,
|
|
97
|
-
line1: addressLine1.value || undefined,
|
|
98
|
-
postalCode: postalCode.value || undefined,
|
|
99
|
-
city: city.value || undefined,
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
customerType.value = updated.customerType;
|
|
105
|
-
vatVerificationStatus.value = updated.vatVerificationStatus;
|
|
60
|
+
apply(await identity.save(draft.value));
|
|
106
61
|
toast.add({
|
|
107
|
-
title:
|
|
62
|
+
title: t("saas.workspace.billing.info_saved"),
|
|
108
63
|
color: "success",
|
|
109
64
|
icon: "i-ph-check-circle",
|
|
110
65
|
});
|
|
@@ -128,13 +83,22 @@ onMounted(() => {
|
|
|
128
83
|
<template>
|
|
129
84
|
<UCard>
|
|
130
85
|
<template #header>
|
|
131
|
-
<div>
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
86
|
+
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
87
|
+
<div>
|
|
88
|
+
<h3 class="font-semibold">
|
|
89
|
+
{{ $t("saas.workspace.billing.info_title") }}
|
|
90
|
+
</h3>
|
|
91
|
+
<p class="text-muted mt-1 text-sm">
|
|
92
|
+
{{ $t("saas.workspace.billing.info_description") }}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
<UBadge
|
|
96
|
+
v-if="!isLoading && !loadFailed && isIncomplete"
|
|
97
|
+
color="warning"
|
|
98
|
+
variant="subtle"
|
|
99
|
+
>
|
|
100
|
+
{{ $t("saas.workspace.billing.identity.to_complete") }}
|
|
101
|
+
</UBadge>
|
|
138
102
|
</div>
|
|
139
103
|
</template>
|
|
140
104
|
|
|
@@ -145,53 +109,13 @@ onMounted(() => {
|
|
|
145
109
|
|
|
146
110
|
<DmsSaasLoadFailure v-else-if="loadFailed" @retry="load" />
|
|
147
111
|
|
|
148
|
-
<form v-else class="flex flex-col gap-4" @submit.prevent="save">
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<template v-if="isBusiness">
|
|
156
|
-
<UFormField :label="$t('saas.register.field.company_name')">
|
|
157
|
-
<UInput v-model="companyName" class="w-full" />
|
|
158
|
-
</UFormField>
|
|
159
|
-
<UFormField :label="$t('saas.register.field.vat_number')">
|
|
160
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
161
|
-
<UInput v-model="vatNumber" class="min-w-40 grow" />
|
|
162
|
-
<UBadge
|
|
163
|
-
v-if="vatVerificationStatus"
|
|
164
|
-
:color="VAT_BADGE_COLORS[vatVerificationStatus]"
|
|
165
|
-
variant="subtle"
|
|
166
|
-
>
|
|
167
|
-
{{ $t(`saas.workspace.billing.vat.${vatVerificationStatus}`) }}
|
|
168
|
-
</UBadge>
|
|
169
|
-
</div>
|
|
170
|
-
</UFormField>
|
|
171
|
-
</template>
|
|
172
|
-
|
|
173
|
-
<h4 class="font-semibold">{{ $t("saas.register.billing_address") }}</h4>
|
|
174
|
-
<UFormField :label="$t('saas.register.field.country')">
|
|
175
|
-
<USelect v-model="country" :items="countryItems" class="w-full" />
|
|
176
|
-
</UFormField>
|
|
177
|
-
<UFormField :label="$t('saas.register.field.address_line1')">
|
|
178
|
-
<UInput v-model="addressLine1" class="w-full" />
|
|
179
|
-
</UFormField>
|
|
180
|
-
<div class="grid grid-cols-2 gap-4">
|
|
181
|
-
<UFormField :label="$t('saas.register.field.postal_code')">
|
|
182
|
-
<UInput v-model="postalCode" class="w-full" />
|
|
183
|
-
</UFormField>
|
|
184
|
-
<UFormField :label="$t('saas.register.field.city')">
|
|
185
|
-
<UInput v-model="city" class="w-full" />
|
|
186
|
-
</UFormField>
|
|
187
|
-
</div>
|
|
188
|
-
|
|
189
|
-
<UFormField
|
|
190
|
-
:label="$t('saas.workspace.billing.billing_email.label')"
|
|
191
|
-
:description="$t('saas.workspace.billing.billing_email.hint')"
|
|
192
|
-
>
|
|
193
|
-
<UInput v-model="billingEmail" type="email" class="w-full" />
|
|
194
|
-
</UFormField>
|
|
112
|
+
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="save">
|
|
113
|
+
<DmsSaasBillingIdentityFields
|
|
114
|
+
v-model="draft"
|
|
115
|
+
:invalid-fields="invalidFields"
|
|
116
|
+
:vat-verification-status="vatVerificationStatus"
|
|
117
|
+
:is-locked="!isTenantOwner"
|
|
118
|
+
/>
|
|
195
119
|
|
|
196
120
|
<div v-if="isTenantOwner" class="flex justify-end">
|
|
197
121
|
<UButton
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, onMounted, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
const FREE_PLAN_PRICE = 0;
|
|
5
|
+
const SUMMARY_SEPARATOR = " · ";
|
|
5
6
|
const FULL_DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
|
6
7
|
year: "numeric",
|
|
7
8
|
month: "long",
|
|
@@ -19,12 +20,17 @@ const {
|
|
|
19
20
|
const { data: billingStatus, load: loadBillingStatus } = useBillingStatus();
|
|
20
21
|
const { formatMajorUnits } = useMoneyFormat();
|
|
21
22
|
const { t, locale } = useI18n();
|
|
23
|
+
const { formatFeatureValue } = usePlanFeatureFormat();
|
|
24
|
+
const { featureLabel } = usePlanFeatureLabel();
|
|
25
|
+
const { workspace, load: loadWorkspace } = useCurrentWorkspace();
|
|
26
|
+
const { isOpen: isComparisonOpen } = usePlanComparison();
|
|
22
27
|
const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
|
|
23
28
|
|
|
24
29
|
const data = ref<TenantPlanResponse | null>(null);
|
|
25
30
|
const isLoading = ref(true);
|
|
26
31
|
const isCancelling = ref(false);
|
|
27
|
-
const
|
|
32
|
+
const isUpgradeOpen = ref(false);
|
|
33
|
+
const upgradeTarget = ref<TenantPlanView | null>(null);
|
|
28
34
|
|
|
29
35
|
const current = computed(() => data.value?.current ?? null);
|
|
30
36
|
const pendingPlan = computed(() => data.value?.pendingPlan ?? null);
|
|
@@ -38,6 +44,15 @@ const isAccessBlocked = computed(
|
|
|
38
44
|
!data.value?.canRecoverComplimentary,
|
|
39
45
|
);
|
|
40
46
|
|
|
47
|
+
/** Settling an invoice only lifts a block when there is one to settle: an
|
|
48
|
+
* operator suspension, or a member who cannot see the invoice, gets the copy
|
|
49
|
+
* that promises nothing about payment. */
|
|
50
|
+
const blockedHintKey = computed(() =>
|
|
51
|
+
billingStatus.value?.unpaidInvoice
|
|
52
|
+
? "saas.workspace.plan.blocked_hint"
|
|
53
|
+
: "saas.workspace.plan.blocked_hint_inactive",
|
|
54
|
+
);
|
|
55
|
+
|
|
41
56
|
/** Plan mutations are owner-only server-side; a member gets the read-only
|
|
42
57
|
* card instead of buttons that can only end in a 403 toast. */
|
|
43
58
|
const isTenantOwner = computed(() => !!billingStatus.value?.isTenantOwner);
|
|
@@ -68,6 +83,34 @@ const changeAction = computed(() =>
|
|
|
68
83
|
: { color: "primary" as const, variant: "solid" as const, key: "go_paid" },
|
|
69
84
|
);
|
|
70
85
|
|
|
86
|
+
/**
|
|
87
|
+
* One line on what the plan includes: the catalogue's own description when it
|
|
88
|
+
* has one, else the plan's key (non-detail) limits.
|
|
89
|
+
*/
|
|
90
|
+
const summary = computed(() => {
|
|
91
|
+
if (current.value?.description) return current.value.description;
|
|
92
|
+
const view = data.value?.available.find(
|
|
93
|
+
(plan) => plan._id === current.value?._id,
|
|
94
|
+
);
|
|
95
|
+
if (!view || !data.value) return "";
|
|
96
|
+
return data.value.features
|
|
97
|
+
.filter(
|
|
98
|
+
(feature) =>
|
|
99
|
+
!feature.isDetailRow &&
|
|
100
|
+
view.featureValues[feature.featureId] !== undefined,
|
|
101
|
+
)
|
|
102
|
+
.map(
|
|
103
|
+
(feature) =>
|
|
104
|
+
`${featureLabel(feature)} ${formatFeatureValue(feature, view.featureValues[feature.featureId], view.currency)}`,
|
|
105
|
+
)
|
|
106
|
+
.join(SUMMARY_SEPARATOR);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function openUpgrade(plan: TenantPlanView): void {
|
|
110
|
+
upgradeTarget.value = plan;
|
|
111
|
+
isUpgradeOpen.value = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
71
114
|
const priceLabel = computed(() =>
|
|
72
115
|
current.value
|
|
73
116
|
? formatMajorUnits(current.value.price, current.value.currency)
|
|
@@ -111,8 +154,15 @@ async function cancelDowngrade(): Promise<void> {
|
|
|
111
154
|
}
|
|
112
155
|
}
|
|
113
156
|
|
|
157
|
+
/** The workspace name only titles the owner's plan comparison, and reading
|
|
158
|
+
* it is owner-only: a member's card never asks. */
|
|
159
|
+
async function loadWorkspaceForOwner(): Promise<void> {
|
|
160
|
+
const status = await loadBillingStatus();
|
|
161
|
+
if (status?.isTenantOwner) await loadWorkspace().catch(() => undefined);
|
|
162
|
+
}
|
|
163
|
+
|
|
114
164
|
onMounted(() => {
|
|
115
|
-
void
|
|
165
|
+
void loadWorkspaceForOwner();
|
|
116
166
|
return fetchPlan(false);
|
|
117
167
|
});
|
|
118
168
|
</script>
|
|
@@ -140,11 +190,11 @@ onMounted(() => {
|
|
|
140
190
|
<div v-else-if="data" class="flex flex-col gap-4">
|
|
141
191
|
<div class="flex flex-wrap items-start gap-4">
|
|
142
192
|
<div class="min-w-60 grow">
|
|
143
|
-
<h2 class="text-lg font-semibold">
|
|
144
|
-
{{ current
|
|
193
|
+
<h2 v-if="current" class="text-lg font-semibold">
|
|
194
|
+
{{ current.name }}
|
|
145
195
|
</h2>
|
|
146
|
-
<p v-if="
|
|
147
|
-
{{
|
|
196
|
+
<p v-if="summary" class="text-muted mt-1 text-sm">
|
|
197
|
+
{{ summary }}
|
|
148
198
|
</p>
|
|
149
199
|
</div>
|
|
150
200
|
<div v-if="current" class="text-right">
|
|
@@ -167,9 +217,9 @@ onMounted(() => {
|
|
|
167
217
|
</div>
|
|
168
218
|
<div class="mt-2 flex justify-between gap-4 font-semibold">
|
|
169
219
|
<span>{{ $t("saas.admission.total") }}</span>
|
|
170
|
-
<span class="tabular-nums">
|
|
171
|
-
formatMajorUnits(0, current.currency)
|
|
172
|
-
|
|
220
|
+
<span class="tabular-nums">
|
|
221
|
+
{{ formatMajorUnits(0, current.currency) }}
|
|
222
|
+
</span>
|
|
173
223
|
</div>
|
|
174
224
|
</div>
|
|
175
225
|
|
|
@@ -181,7 +231,7 @@ onMounted(() => {
|
|
|
181
231
|
</p>
|
|
182
232
|
|
|
183
233
|
<p v-if="isAccessBlocked" class="text-muted text-sm">
|
|
184
|
-
{{ $t(
|
|
234
|
+
{{ $t(blockedHintKey) }}
|
|
185
235
|
</p>
|
|
186
236
|
|
|
187
237
|
<p v-else-if="data.isPlanChangeLocked" class="text-muted text-sm">
|
|
@@ -216,6 +266,14 @@ onMounted(() => {
|
|
|
216
266
|
:current-plan-id="current?._id ?? null"
|
|
217
267
|
:pending-plan-id="pendingPlan?.planId ?? null"
|
|
218
268
|
:is-recovery="data.canRecoverComplimentary"
|
|
269
|
+
:is-current-paid="isPaid"
|
|
270
|
+
:workspace-name="workspace?.name ?? null"
|
|
271
|
+
@changed="refresh"
|
|
272
|
+
@upgrade="openUpgrade"
|
|
273
|
+
/>
|
|
274
|
+
<DmsSaasPlanUpgradeModal
|
|
275
|
+
v-model:open="isUpgradeOpen"
|
|
276
|
+
:plan="upgradeTarget"
|
|
219
277
|
@changed="refresh"
|
|
220
278
|
/>
|
|
221
279
|
</div>
|
|
@@ -20,12 +20,19 @@ interface UserSummaryData {
|
|
|
20
20
|
workspaces: Array<{ isTenantOwner: boolean }>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
const MISSING_DATE = "—";
|
|
24
|
+
const DAY_FORMAT: Intl.DateTimeFormatOptions = {
|
|
25
|
+
day: "2-digit",
|
|
26
|
+
month: "short",
|
|
27
|
+
year: "numeric",
|
|
28
|
+
};
|
|
29
|
+
|
|
23
30
|
const props = defineProps<{
|
|
24
31
|
routeParams?: Record<string, string>;
|
|
25
32
|
}>();
|
|
26
33
|
|
|
27
34
|
const { $authFetch } = useAuthFetch();
|
|
28
|
-
const { t } = useI18n();
|
|
35
|
+
const { t, locale } = useI18n();
|
|
29
36
|
|
|
30
37
|
const userId = computed(() => props.routeParams?.id ?? "");
|
|
31
38
|
const user = ref<UserSummaryData | null>(null);
|
|
@@ -48,13 +55,8 @@ const memberCount = computed(
|
|
|
48
55
|
() => user.value?.workspaces.filter((w) => !w.isTenantOwner).length ?? 0,
|
|
49
56
|
);
|
|
50
57
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
return new Date(value).toLocaleDateString("fr-FR", {
|
|
54
|
-
day: "2-digit",
|
|
55
|
-
month: "short",
|
|
56
|
-
year: "numeric",
|
|
57
|
-
});
|
|
58
|
+
function formatDay(value: string | null): string {
|
|
59
|
+
return formatDate(value, locale.value, DAY_FORMAT) ?? MISSING_DATE;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
async function load(): Promise<void> {
|
|
@@ -114,11 +116,11 @@ onMounted(load);
|
|
|
114
116
|
<dl class="grid grid-cols-2 gap-3 text-sm lg:grid-cols-4">
|
|
115
117
|
<div>
|
|
116
118
|
<dt class="text-muted">{{ $t("saas.users.column.created_at") }}</dt>
|
|
117
|
-
<dd class="mt-0.5">{{
|
|
119
|
+
<dd class="mt-0.5">{{ formatDay(user.createdAt) }}</dd>
|
|
118
120
|
</div>
|
|
119
121
|
<div>
|
|
120
122
|
<dt class="text-muted">{{ $t("saas.users.info.last_active") }}</dt>
|
|
121
|
-
<dd class="mt-0.5">{{
|
|
123
|
+
<dd class="mt-0.5">{{ formatDay(user.lastActiveAt) }}</dd>
|
|
122
124
|
</div>
|
|
123
125
|
<div>
|
|
124
126
|
<dt class="text-muted">{{ $t("saas.users.info.language") }}</dt>
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
interface PlanOption {
|
|
5
|
+
_id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
isActive: boolean;
|
|
8
|
+
isDeleted: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type CreatedOwner =
|
|
12
|
+
| { kind: "added"; userId: string }
|
|
13
|
+
| { kind: "invited"; inviteId: string };
|
|
14
|
+
|
|
15
|
+
interface CreatedWorkspace {
|
|
16
|
+
tenantId: string;
|
|
17
|
+
owner: CreatedOwner;
|
|
18
|
+
invitationEmail: "sent" | "failed" | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
onSuccessCallback?: () => void;
|
|
23
|
+
onCancelCallback?: () => void;
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const PLANS_ENDPOINT = "/api/saas/plans";
|
|
27
|
+
const CREATE_ENDPOINT = "/modules/saas/customers/workspaces/create";
|
|
28
|
+
const WARNING_TOAST_DURATION = 0;
|
|
29
|
+
|
|
30
|
+
const { $authFetch } = useAuthFetch();
|
|
31
|
+
const nuxtApp = useDmsApp();
|
|
32
|
+
const toast = useToast();
|
|
33
|
+
const { resolveApiError } = useApiErrorMessage();
|
|
34
|
+
const { fetchAndCopy } = useInvitationLink();
|
|
35
|
+
|
|
36
|
+
const plans = ref<PlanOption[]>([]);
|
|
37
|
+
const name = ref("");
|
|
38
|
+
const planId = ref<string | undefined>(undefined);
|
|
39
|
+
const ownerEmail = ref("");
|
|
40
|
+
const freeUntil = ref("");
|
|
41
|
+
const isLoading = ref(true);
|
|
42
|
+
const isSubmitting = ref(false);
|
|
43
|
+
const errorMessage = ref<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const planItems = computed(() =>
|
|
46
|
+
plans.value
|
|
47
|
+
.filter((plan) => plan.isActive && !plan.isDeleted)
|
|
48
|
+
.map((plan) => ({ value: plan._id, label: plan.name })),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const canSubmit = computed(
|
|
52
|
+
() => !!name.value.trim() && !!planId.value && !!ownerEmail.value.trim(),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
async function load(): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
plans.value = await $authFetch<PlanOption[]>(PLANS_ENDPOINT);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
errorMessage.value = resolveApiError(
|
|
60
|
+
error,
|
|
61
|
+
"saas.workspaces.admin.create.error.load",
|
|
62
|
+
);
|
|
63
|
+
} finally {
|
|
64
|
+
isLoading.value = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function notifyCreated(created: CreatedWorkspace): void {
|
|
69
|
+
const t = nuxtApp.$i18n.t;
|
|
70
|
+
const { owner } = created;
|
|
71
|
+
if (created.invitationEmail !== "failed" || owner.kind !== "invited") {
|
|
72
|
+
toast.add({
|
|
73
|
+
title: t("saas.workspaces.admin.create.success"),
|
|
74
|
+
color: "success",
|
|
75
|
+
icon: "i-ph-check-circle",
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// The workspace exists and the owner is invited, but nobody told them: the
|
|
80
|
+
// operator has to hand the link over, so the notice stays until dismissed.
|
|
81
|
+
toast.add({
|
|
82
|
+
title: t("saas.workspaces.admin.create.email_failed.title"),
|
|
83
|
+
description: t("saas.workspaces.admin.create.email_failed.description"),
|
|
84
|
+
color: "warning",
|
|
85
|
+
icon: "i-ph-warning",
|
|
86
|
+
duration: WARNING_TOAST_DURATION,
|
|
87
|
+
actions: [
|
|
88
|
+
{
|
|
89
|
+
label: t("saas.workspaces.invitations.action.copy_link"),
|
|
90
|
+
icon: "i-ph-link",
|
|
91
|
+
onClick: () =>
|
|
92
|
+
fetchAndCopy({ tenantId: created.tenantId, inviteId: owner.inviteId }),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function submit(): Promise<void> {
|
|
99
|
+
if (isSubmitting.value || !canSubmit.value) return;
|
|
100
|
+
errorMessage.value = null;
|
|
101
|
+
isSubmitting.value = true;
|
|
102
|
+
try {
|
|
103
|
+
const created = await $authFetch<CreatedWorkspace>(CREATE_ENDPOINT, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: {
|
|
106
|
+
name: name.value.trim(),
|
|
107
|
+
planId: planId.value,
|
|
108
|
+
ownerEmail: ownerEmail.value.trim(),
|
|
109
|
+
freeUntil: freeUntil.value || null,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
notifyCreated(created);
|
|
113
|
+
props.onSuccessCallback?.();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
errorMessage.value = resolveApiError(
|
|
116
|
+
error,
|
|
117
|
+
"saas.workspaces.admin.create.error.failed",
|
|
118
|
+
);
|
|
119
|
+
} finally {
|
|
120
|
+
isSubmitting.value = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onMounted(load);
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<div v-if="isLoading" class="flex flex-col gap-3">
|
|
129
|
+
<USkeleton class="h-10 w-full" />
|
|
130
|
+
<USkeleton class="h-10 w-full" />
|
|
131
|
+
</div>
|
|
132
|
+
<form v-else class="flex flex-col gap-4" @submit.prevent="submit">
|
|
133
|
+
<UFormField
|
|
134
|
+
:label="$t('saas.workspaces.admin.create.field.name')"
|
|
135
|
+
:description="$t('saas.workspaces.admin.create.field.name_description')"
|
|
136
|
+
required
|
|
137
|
+
>
|
|
138
|
+
<UInput
|
|
139
|
+
v-model="name"
|
|
140
|
+
:placeholder="$t('saas.workspaces.admin.create.placeholder.name')"
|
|
141
|
+
class="w-full"
|
|
142
|
+
autofocus
|
|
143
|
+
/>
|
|
144
|
+
</UFormField>
|
|
145
|
+
<UFormField
|
|
146
|
+
:label="$t('saas.workspaces.admin.create.field.plan')"
|
|
147
|
+
:description="$t('saas.workspaces.admin.create.field.plan_description')"
|
|
148
|
+
required
|
|
149
|
+
>
|
|
150
|
+
<USelect
|
|
151
|
+
v-model="planId"
|
|
152
|
+
:items="planItems"
|
|
153
|
+
:placeholder="$t('saas.workspaces.admin.create.placeholder.plan')"
|
|
154
|
+
class="w-full"
|
|
155
|
+
/>
|
|
156
|
+
</UFormField>
|
|
157
|
+
<UFormField
|
|
158
|
+
:label="$t('saas.workspaces.admin.create.field.owner_email')"
|
|
159
|
+
:description="
|
|
160
|
+
$t('saas.workspaces.admin.create.field.owner_email_description')
|
|
161
|
+
"
|
|
162
|
+
required
|
|
163
|
+
>
|
|
164
|
+
<UInput
|
|
165
|
+
v-model="ownerEmail"
|
|
166
|
+
type="email"
|
|
167
|
+
:placeholder="$t('saas.workspaces.admin.create.placeholder.owner_email')"
|
|
168
|
+
class="w-full"
|
|
169
|
+
/>
|
|
170
|
+
</UFormField>
|
|
171
|
+
<UFormField
|
|
172
|
+
:label="$t('saas.workspaces.admin.create.field.free_until')"
|
|
173
|
+
:description="
|
|
174
|
+
$t('saas.workspaces.admin.create.field.free_until_description')
|
|
175
|
+
"
|
|
176
|
+
>
|
|
177
|
+
<UInput v-model="freeUntil" type="date" class="w-full" />
|
|
178
|
+
</UFormField>
|
|
179
|
+
|
|
180
|
+
<p v-if="errorMessage" class="text-error text-sm">{{ errorMessage }}</p>
|
|
181
|
+
|
|
182
|
+
<div class="flex justify-end gap-2">
|
|
183
|
+
<UButton
|
|
184
|
+
v-if="props.onCancelCallback"
|
|
185
|
+
color="neutral"
|
|
186
|
+
variant="ghost"
|
|
187
|
+
:disabled="isSubmitting"
|
|
188
|
+
@click="props.onCancelCallback()"
|
|
189
|
+
>
|
|
190
|
+
{{ $t("common.cancel") }}
|
|
191
|
+
</UButton>
|
|
192
|
+
<UButton
|
|
193
|
+
type="submit"
|
|
194
|
+
color="primary"
|
|
195
|
+
:loading="isSubmitting"
|
|
196
|
+
:disabled="!canSubmit"
|
|
197
|
+
>
|
|
198
|
+
{{ $t("saas.workspaces.admin.create.submit") }}
|
|
199
|
+
</UButton>
|
|
200
|
+
</div>
|
|
201
|
+
</form>
|
|
202
|
+
</template>
|