@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.
Files changed (177) hide show
  1. package/README.md +257 -10
  2. package/dist/billing-state/index.d.ts +1 -0
  3. package/dist/billing-state/index.js +1 -0
  4. package/dist/billing-state/index.js.map +1 -1
  5. package/dist/billing-state/past-due-banner.d.ts +4 -0
  6. package/dist/billing-state/past-due-banner.js +25 -0
  7. package/dist/billing-state/past-due-banner.js.map +1 -0
  8. package/dist/config/runtime.d.ts +6 -1
  9. package/dist/config/runtime.js +50 -1
  10. package/dist/config/runtime.js.map +1 -1
  11. package/dist/crons/recompute-billing-state.js +4 -1
  12. package/dist/crons/recompute-billing-state.js.map +1 -1
  13. package/dist/data-api/platformOwner/index.d.ts +1 -0
  14. package/dist/data-api/platformOwner/index.js +1 -0
  15. package/dist/data-api/platformOwner/index.js.map +1 -1
  16. package/dist/data-api/platformOwner/invitations.d.ts +22 -0
  17. package/dist/data-api/platformOwner/invitations.js +121 -0
  18. package/dist/data-api/platformOwner/invitations.js.map +1 -0
  19. package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
  20. package/dist/data-api/platformOwner/workspaces.js +64 -12
  21. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  22. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  23. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  24. package/dist/hooks/register.js +2 -1
  25. package/dist/hooks/register.js.map +1 -1
  26. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  27. package/dist/implementations/dms-saas/billing.js +6 -0
  28. package/dist/implementations/dms-saas/billing.js.map +1 -1
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/invoice-line-items/injection.d.ts +10 -0
  32. package/dist/invoice-line-items/injection.js +7 -3
  33. package/dist/invoice-line-items/injection.js.map +1 -1
  34. package/dist/operator-actions/index.d.ts +1 -0
  35. package/dist/operator-actions/index.js +1 -0
  36. package/dist/operator-actions/index.js.map +1 -1
  37. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  38. package/dist/operator-actions/invitation-commands.js +46 -0
  39. package/dist/operator-actions/invitation-commands.js.map +1 -0
  40. package/dist/operator-actions/types.d.ts +1 -1
  41. package/dist/pages/platform/dashboard.js +2 -4
  42. package/dist/pages/platform/dashboard.js.map +1 -1
  43. package/dist/pages/platform/workspaces/detail.js +40 -1
  44. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  45. package/dist/pages/platform/workspaces/index.d.ts +2 -8
  46. package/dist/pages/platform/workspaces/index.js +27 -111
  47. package/dist/pages/platform/workspaces/index.js.map +1 -1
  48. package/dist/pages/public/legal/privacy-policy.js +2 -1
  49. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  50. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  51. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  52. package/dist/pages/public/legal/terms-of-use.js +2 -1
  53. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  54. package/dist/plans/index.d.ts +1 -0
  55. package/dist/plans/index.js +1 -0
  56. package/dist/plans/index.js.map +1 -1
  57. package/dist/plans/platform-support.d.ts +2 -0
  58. package/dist/plans/platform-support.js +19 -0
  59. package/dist/plans/platform-support.js.map +1 -0
  60. package/dist/plans/seat-capacity.d.ts +9 -2
  61. package/dist/plans/seat-capacity.js +59 -12
  62. package/dist/plans/seat-capacity.js.map +1 -1
  63. package/dist/plans/seat-hooks.js +34 -13
  64. package/dist/plans/seat-hooks.js.map +1 -1
  65. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  66. package/dist/routes/platformOwner/dashboard.js +31 -17
  67. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  68. package/dist/routes/platformOwner/index.d.ts +1 -0
  69. package/dist/routes/platformOwner/index.js +1 -0
  70. package/dist/routes/platformOwner/index.js.map +1 -1
  71. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  72. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  73. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  74. package/dist/routes/public/register.d.ts +7 -3
  75. package/dist/routes/public/register.js +42 -27
  76. package/dist/routes/public/register.js.map +1 -1
  77. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  78. package/dist/routes/tenant/tenant-billing.js +34 -26
  79. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  80. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  81. package/dist/routes/tenant/tenant-plan.js +27 -2
  82. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  83. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  84. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  85. package/dist/routes/tenant/workspaces.d.ts +2 -0
  86. package/dist/routes/tenant/workspaces.js +10 -4
  87. package/dist/routes/tenant/workspaces.js.map +1 -1
  88. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  89. package/dist/stripe/upcoming-invoice.js +42 -0
  90. package/dist/stripe/upcoming-invoice.js.map +1 -0
  91. package/dist/stripe/webhook-dispatch.js +2 -0
  92. package/dist/stripe/webhook-dispatch.js.map +1 -1
  93. package/dist/types/config.d.ts +9 -0
  94. package/dist/types/config.js +7 -0
  95. package/dist/types/config.js.map +1 -1
  96. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  97. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  98. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  99. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  100. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  101. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  102. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  103. package/dist/upcoming-invoice/eligibility.js +52 -0
  104. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  105. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  106. package/dist/upcoming-invoice/invalidation.js +39 -0
  107. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  108. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  109. package/dist/upcoming-invoice/mapping.js +82 -0
  110. package/dist/upcoming-invoice/mapping.js.map +1 -0
  111. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  112. package/dist/upcoming-invoice/outcomes.js +15 -0
  113. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  114. package/dist/upcoming-invoice/preview.d.ts +3 -0
  115. package/dist/upcoming-invoice/preview.js +80 -0
  116. package/dist/upcoming-invoice/preview.js.map +1 -0
  117. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  118. package/dist/upcoming-invoice/pricing.js +84 -0
  119. package/dist/upcoming-invoice/pricing.js.map +1 -0
  120. package/dist/upcoming-invoice/usage.d.ts +14 -0
  121. package/dist/upcoming-invoice/usage.js +53 -0
  122. package/dist/upcoming-invoice/usage.js.map +1 -0
  123. package/dist/workspaces/billing-identity.d.ts +19 -0
  124. package/dist/workspaces/billing-identity.js +51 -0
  125. package/dist/workspaces/billing-identity.js.map +1 -0
  126. package/dist/workspaces/default-plan.d.ts +5 -0
  127. package/dist/workspaces/default-plan.js +109 -0
  128. package/dist/workspaces/default-plan.js.map +1 -0
  129. package/dist/workspaces/index.d.ts +2 -0
  130. package/dist/workspaces/index.js +2 -0
  131. package/dist/workspaces/index.js.map +1 -1
  132. package/dist/workspaces/invitations.d.ts +26 -0
  133. package/dist/workspaces/invitations.js +112 -0
  134. package/dist/workspaces/invitations.js.map +1 -0
  135. package/dist/workspaces/provisioning.d.ts +3 -3
  136. package/dist/workspaces/provisioning.js +39 -11
  137. package/dist/workspaces/provisioning.js.map +1 -1
  138. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  139. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  140. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  141. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  142. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  143. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  144. package/frontend-vue/app/components/RefundButton.vue +10 -1
  145. package/frontend-vue/app/components/Register.vue +49 -92
  146. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  147. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  148. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  149. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  150. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  151. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  152. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  153. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  154. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  155. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  156. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  157. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  158. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  159. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  160. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  161. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  162. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  163. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  164. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  165. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  166. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  167. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  168. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  169. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  170. package/frontend-vue/app/types/runtime-config.ts +13 -0
  171. package/frontend-vue/dms.frontend.ts +10 -4
  172. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  173. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  174. package/frontend-vue/package.json +0 -1
  175. package/frontend-vue/pnpm-lock.yaml +1 -125
  176. package/package.json +5 -5
  177. 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 { countryItems } = useBillingCountries();
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 customerType = ref<"individual" | "business" | null>(null);
44
- const companyName = ref("");
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 billingEmail = ref("");
48
- const country = ref("");
49
- const addressLine1 = ref("");
50
- const postalCode = ref("");
51
- const city = ref("");
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
- const isBusiness = computed(() => customerType.value === "business");
27
+ /** The badge reflects what is stored, not what is being typed. */
28
+ const isIncomplete = computed(() => savedMissingFields.value.length > 0);
57
29
 
58
- const customerTypeLabel = computed(() => {
59
- if (!customerType.value) return "—";
60
- return nuxtApp.$i18n.t(`saas.register.customer_type.${customerType.value}`);
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
- const info = await $authFetch<BillingInfoResponse>(BILLING_INFO_ENDPOINT);
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
- const updated = await $authFetch<BillingInfoResponse>(
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: nuxtApp.$i18n.t("saas.workspace.billing.info_saved"),
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
- <h3 class="font-semibold">
133
- {{ $t("saas.workspace.billing.info_title") }}
134
- </h3>
135
- <p class="text-muted mt-1 text-sm">
136
- {{ $t("saas.workspace.billing.info_description") }}
137
- </p>
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
- <UFormField :label="$t('saas.register.customer_type.label')">
150
- <UBadge color="neutral" variant="subtle">
151
- {{ customerTypeLabel }}
152
- </UBadge>
153
- </UFormField>
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 isComparisonOpen = ref(false);
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 loadBillingStatus();
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?.name ?? $t("saas.workspace.plan.none") }}
193
+ <h2 v-if="current" class="text-lg font-semibold">
194
+ {{ current.name }}
145
195
  </h2>
146
- <p v-if="current?.description" class="text-muted mt-1 text-sm">
147
- {{ current.description }}
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
- }}</span>
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("saas.workspace.plan.blocked_hint") }}
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 formatDate(value: string | null): string {
52
- if (!value) return "—";
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">{{ formatDate(user.createdAt) }}</dd>
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">{{ formatDate(user.lastActiveAt) }}</dd>
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>