Merge branch 'main' into iain/web-2142-multi-region-db-connection-health
This commit is contained in:
Коммит
1ac1fe013f
|
@ -10,14 +10,22 @@ const getDbClients = require('../knex')
|
|||
async function main() {
|
||||
const cmdArgs = process.argv.slice(2)
|
||||
|
||||
const [filePath, userId, streamId, branchName, commitMessage, fileId, regionName] =
|
||||
cmdArgs
|
||||
const [
|
||||
filePath,
|
||||
userId,
|
||||
streamId,
|
||||
branchName,
|
||||
commitMessage,
|
||||
fileId,
|
||||
branchId,
|
||||
regionName
|
||||
] = cmdArgs
|
||||
const logger = Observability.extendLoggerComponent(
|
||||
parentLogger.child({ streamId, branchName, userId, fileId, filePath }),
|
||||
'ifc'
|
||||
)
|
||||
|
||||
logger.info('ARGV: ', filePath, userId, streamId, branchName, commitMessage)
|
||||
logger.info('ARGV: ', filePath, userId, streamId, branchName, branchId, commitMessage)
|
||||
|
||||
const data = fs.readFileSync(filePath)
|
||||
|
||||
|
@ -27,6 +35,7 @@ async function main() {
|
|||
userId,
|
||||
message: commitMessage || ' Imported file',
|
||||
fileId,
|
||||
branchId,
|
||||
logger
|
||||
}
|
||||
if (branchName) ifcInput.branchName = branchName
|
||||
|
|
|
@ -14,6 +14,7 @@ const parseAndCreateCommitFactory =
|
|||
userId,
|
||||
message = 'Manual IFC file upload',
|
||||
fileId,
|
||||
branchId,
|
||||
logger
|
||||
}) => {
|
||||
if (!logger) {
|
||||
|
@ -45,12 +46,7 @@ const parseAndCreateCommitFactory =
|
|||
totalChildrenCount: tCount
|
||||
}
|
||||
|
||||
const branch = await serverApi.getBranchByNameAndStreamId({
|
||||
streamId,
|
||||
name: branchName
|
||||
})
|
||||
|
||||
if (!branch) {
|
||||
if (!branchId) {
|
||||
logger.info("Branch '{branchName}' not found, creating it.")
|
||||
await serverApi.createBranch({
|
||||
name: branchName,
|
||||
|
|
|
@ -81,7 +81,8 @@ async function doTask(mainDb, regionName, taskDb, task) {
|
|||
fileSize: fileSizeForMetric,
|
||||
userId: info.userId,
|
||||
streamId: info.streamId,
|
||||
branchName: info.branchName
|
||||
branchName: info.branchName,
|
||||
branchId: info.branchId
|
||||
})
|
||||
fs.mkdirSync(TMP_INPUT_DIR, { recursive: true })
|
||||
|
||||
|
@ -136,6 +137,7 @@ async function doTask(mainDb, regionName, taskDb, task) {
|
|||
info.branchName,
|
||||
`File upload: ${info.fileName}`,
|
||||
info.id,
|
||||
existingBranch?.id,
|
||||
regionName
|
||||
],
|
||||
{
|
||||
|
@ -153,7 +155,10 @@ async function doTask(mainDb, regionName, taskDb, task) {
|
|||
info.userId,
|
||||
info.streamId,
|
||||
info.branchName,
|
||||
`File upload: ${info.fileName}`
|
||||
`File upload: ${info.fileName}`,
|
||||
info.id,
|
||||
existingBranch?.id,
|
||||
regionName
|
||||
],
|
||||
{
|
||||
USER_TOKEN: tempUserToken
|
||||
|
@ -178,7 +183,10 @@ async function doTask(mainDb, regionName, taskDb, task) {
|
|||
info.userId,
|
||||
info.streamId,
|
||||
info.branchName,
|
||||
`File upload: ${info.fileName}`
|
||||
`File upload: ${info.fileName}`,
|
||||
info.id,
|
||||
existingBranch?.id,
|
||||
regionName
|
||||
],
|
||||
{
|
||||
USER_TOKEN: tempUserToken
|
||||
|
|
|
@ -190,9 +190,6 @@ const toggleSettingsDialog = (target: AvailableSettingsMenuKeys) => {
|
|||
const deleteSettingsQuery = (): void => {
|
||||
const currentQueryParams = { ...route.query }
|
||||
delete currentQueryParams.settings
|
||||
delete currentQueryParams.workspace
|
||||
delete currentQueryParams.error
|
||||
|
||||
router.push({ query: currentQueryParams })
|
||||
}
|
||||
|
||||
|
@ -202,8 +199,6 @@ const openFeedbackDialog = () => {
|
|||
|
||||
onMounted(() => {
|
||||
const settingsQuery = route.query?.settings
|
||||
const workspaceQuery = route.query?.workspace
|
||||
const errorQuery = route.query?.error
|
||||
|
||||
if (settingsQuery && isString(settingsQuery)) {
|
||||
if (settingsQuery.includes('server') && !isAdmin.value) {
|
||||
|
@ -215,22 +210,6 @@ onMounted(() => {
|
|||
return
|
||||
}
|
||||
|
||||
if (workspaceQuery && isString(workspaceQuery)) {
|
||||
workspaceSettingsDialogTarget.value = workspaceQuery
|
||||
|
||||
if (errorQuery && isString(errorQuery)) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorQuery
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'SSO settings successfully updated'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
showSettingsDialog.value = true
|
||||
settingsDialogTarget.value = settingsQuery
|
||||
deleteSettingsQuery()
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
<template>
|
||||
<section>
|
||||
<div class="md:mx-auto pb-6 md:pb-0">
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader title="Billing" text="Your workspace billing details" />
|
||||
<template v-if="isBillingIntegrationEnabled">
|
||||
<div class="flex flex-col gap-y-4 md:gap-y-6">
|
||||
<BillingAlert v-if="workspaceResult" :workspace="workspaceResult.workspace" />
|
||||
<BillingAlert
|
||||
v-if="
|
||||
workspaceResult &&
|
||||
workspaceResult.workspace?.plan?.status !== WorkspacePlanStatuses.Valid
|
||||
"
|
||||
:workspace="workspaceResult.workspace"
|
||||
/>
|
||||
<SettingsSectionHeader title="Billing summary" subheading class="pt-4" />
|
||||
<div class="border border-outline-3 rounded-lg">
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x"
|
||||
class="grid grid-cols-1 md:grid-cols-3 divide-y divide-outline-3 md:divide-y-0 md:divide-x"
|
||||
>
|
||||
<div class="p-5 pt-4 flex flex-col gap-y-1">
|
||||
<h3 class="text-body-xs text-foreground-2 pb-2">
|
||||
|
@ -17,7 +23,7 @@
|
|||
<p class="text-heading-lg text-foreground capitalize">
|
||||
{{ currentPlan?.name ?? WorkspacePlans.Team }} plan
|
||||
</p>
|
||||
<p class="text-body-xs text-foreground-2">
|
||||
<p v-if="isPurchasablePlan" class="text-body-xs text-foreground-2">
|
||||
£{{ seatPrice }} per seat/month, billed
|
||||
{{
|
||||
subscription?.billingInterval === BillingInterval.Yearly
|
||||
|
@ -36,16 +42,22 @@
|
|||
: 'Monthly bill'
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-heading-lg text-foreground capitalize">Coming soon</p>
|
||||
<p class="text-heading-lg text-foreground capitalize">
|
||||
{{ isPurchasablePlan ? 'Coming soon' : 'Not applicable' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-5 pt-4 flex flex-col gap-y-1">
|
||||
<h3 class="text-body-xs text-foreground-2 pb-2">
|
||||
{{ isTrialPeriod ? 'First payment due' : 'Next payment due' }}
|
||||
{{
|
||||
isTrialPeriod && isPurchasablePlan
|
||||
? 'First payment due'
|
||||
: 'Next payment due'
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-heading-lg text-foreground capitalize">
|
||||
{{ nextPaymentDue }}
|
||||
{{ isPurchasablePlan ? nextPaymentDue : 'Not applicable' }}
|
||||
</p>
|
||||
<p v-if="isPaidPlan" class="text-body-xs text-foreground-2">
|
||||
<p v-if="isPurchasablePlan" class="text-body-xs text-foreground-2">
|
||||
<span class="capitalize">
|
||||
{{
|
||||
subscription?.billingInterval === BillingInterval.Yearly
|
||||
|
@ -58,7 +70,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActivePlan"
|
||||
v-if="isActivePlan && isPurchasablePlan"
|
||||
class="flex flex-row gap-x-4 p-5 items-center border-t border-outline-3"
|
||||
>
|
||||
<div class="text-body-xs gap-y-2 flex-1">
|
||||
|
@ -77,6 +89,7 @@
|
|||
class="pt-6"
|
||||
:workspace-id="workspaceId"
|
||||
:current-plan="currentPlan"
|
||||
:is-admin="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -99,10 +112,12 @@ import {
|
|||
import { useBillingActions } from '~/lib/billing/composables/actions'
|
||||
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesBilling_Workspace on Workspace {
|
||||
...BillingAlert_Workspace
|
||||
id
|
||||
role
|
||||
plan {
|
||||
...SettingsWorkspacesBillingPricingTable_WorkspacePlan
|
||||
name
|
||||
|
@ -120,13 +135,6 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
|
||||
const seatPrices = ref({
|
||||
[WorkspacePlans.Team]: pricingPlansConfig.plans[WorkspacePlans.Team].cost,
|
||||
[WorkspacePlans.Pro]: pricingPlansConfig.plans[WorkspacePlans.Pro].cost,
|
||||
[WorkspacePlans.Business]: pricingPlansConfig.plans[WorkspacePlans.Business].cost
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { result: workspaceResult } = useQuery(
|
||||
settingsWorkspaceBillingQuery,
|
||||
() => ({
|
||||
|
@ -136,15 +144,16 @@ const { result: workspaceResult } = useQuery(
|
|||
enabled: isBillingIntegrationEnabled
|
||||
})
|
||||
)
|
||||
const { billingPortalRedirect, cancelCheckoutSession } = useBillingActions()
|
||||
const { billingPortalRedirect } = useBillingActions()
|
||||
|
||||
const seatPrices = ref({
|
||||
[WorkspacePlans.Team]: pricingPlansConfig.plans[WorkspacePlans.Team].cost,
|
||||
[WorkspacePlans.Pro]: pricingPlansConfig.plans[WorkspacePlans.Pro].cost,
|
||||
[WorkspacePlans.Business]: pricingPlansConfig.plans[WorkspacePlans.Business].cost
|
||||
})
|
||||
|
||||
const currentPlan = computed(() => workspaceResult.value?.workspace.plan)
|
||||
const subscription = computed(() => workspaceResult.value?.workspace.subscription)
|
||||
const isPaidPlan = computed(
|
||||
() =>
|
||||
currentPlan.value?.name !== WorkspacePlans.Academia &&
|
||||
currentPlan.value?.name !== WorkspacePlans.Unlimited
|
||||
)
|
||||
const isTrialPeriod = computed(
|
||||
() =>
|
||||
currentPlan.value?.status === WorkspacePlanStatuses.Trial ||
|
||||
|
@ -156,6 +165,13 @@ const isActivePlan = computed(
|
|||
currentPlan.value?.status !== WorkspacePlanStatuses.Trial &&
|
||||
currentPlan.value?.status !== WorkspacePlanStatuses.Canceled
|
||||
)
|
||||
const isPurchasablePlan = computed(
|
||||
() =>
|
||||
currentPlan.value?.name === WorkspacePlans.Team ||
|
||||
currentPlan.value?.name === WorkspacePlans.Pro ||
|
||||
currentPlan.value?.name === WorkspacePlans.Business ||
|
||||
!currentPlan.value?.name // no plan equals pro trial plan
|
||||
)
|
||||
const seatPrice = computed(() =>
|
||||
currentPlan.value && subscription.value
|
||||
? seatPrices.value[currentPlan.value.name as keyof typeof seatPrices.value][
|
||||
|
@ -167,18 +183,12 @@ const seatPrice = computed(() =>
|
|||
)
|
||||
const nextPaymentDue = computed(() =>
|
||||
currentPlan.value
|
||||
? isPaidPlan.value
|
||||
? isPurchasablePlan.value
|
||||
? dayjs(subscription.value?.currentBillingCycleEnd).format('MMMM D, YYYY')
|
||||
: 'Never'
|
||||
: dayjs().add(30, 'days').format('MMMM D, YYYY')
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const paymentStatusQuery = route.query?.payment_status
|
||||
const sessionIdQuery = route.query?.session_id
|
||||
|
||||
if (sessionIdQuery && String(paymentStatusQuery) === WorkspacePlanStatuses.Canceled) {
|
||||
cancelCheckoutSession(String(sessionIdQuery), props.workspaceId)
|
||||
}
|
||||
})
|
||||
const isAdmin = computed(
|
||||
() => workspaceResult.value?.workspace.role === Roles.Workspace.Admin
|
||||
)
|
||||
</script>
|
||||
|
|
|
@ -1,156 +0,0 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-y-6">
|
||||
<div class="flex justify-between">
|
||||
<SettingsSectionHeader
|
||||
:title="hasTrialPlan ? 'Start your subscription' : 'Upgrade your plan'"
|
||||
subheading
|
||||
/>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="text-foreground-3 text-body-xs">Save 20% with annual billing</p>
|
||||
<FormSwitch v-model="isYearlyPlan" :show-label="false" name="annual billing" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full flex flex-col">
|
||||
<thead>
|
||||
<tr class="w-full flex">
|
||||
<th class="w-1/4 flex pl-5 pr-6 pt-4 pb-2 font-medium">
|
||||
<h4>Compare plans</h4>
|
||||
</th>
|
||||
<th
|
||||
v-for="plan in pricingPlans"
|
||||
:key="plan.name"
|
||||
class="w-1/4 flex flex-col gap-y-1 px-6 pt-4 pb-2 font-normal"
|
||||
:class="[
|
||||
plan.name === WorkspacePlans.Team
|
||||
? 'border border-b-0 border-outline-3 bg-foundation-2 rounded-t-lg'
|
||||
: ''
|
||||
]"
|
||||
scope="col"
|
||||
>
|
||||
<h4 class="text-foreground text-body-xs">
|
||||
Workspace
|
||||
<span class="capitalize">{{ plan.name }}</span>
|
||||
</h4>
|
||||
<p class="text-foreground text-heading font-normal">
|
||||
£{{
|
||||
isYearlyPlan
|
||||
? plan.cost.yearly[Roles.Workspace.Member]
|
||||
: plan.cost.monthly[Roles.Workspace.Member]
|
||||
}}
|
||||
per seat/month
|
||||
</p>
|
||||
<p class="text-foreground-2 text-body-2xs pt-1">
|
||||
Billed {{ isYearlyPlan ? 'annually' : 'monthly' }}
|
||||
</p>
|
||||
<FormButton
|
||||
:color="plan.name === WorkspacePlans.Team ? 'primary' : 'outline'"
|
||||
:disabled="!hasTrialPlan && !canUpgradeToPlan(plan.name)"
|
||||
class="mt-3"
|
||||
@click="onUpgradePlanClick(plan.name)"
|
||||
>
|
||||
{{ hasTrialPlan ? 'Subscribe' : 'Upgrade' }} to
|
||||
<span class="capitalize">{{ plan.name }}</span>
|
||||
</FormButton>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="w-full flex flex-col">
|
||||
<tr v-for="(feature, key, index) in features" :key="key" class="flex">
|
||||
<th
|
||||
class="font-normal text-foreground text-body-xs w-1/4 pr-3 pt-1"
|
||||
scope="row"
|
||||
>
|
||||
<div class="border-b border-outline-3 min-h-[42px] pl-5 flex items-center">
|
||||
{{ feature.name }}
|
||||
</div>
|
||||
</th>
|
||||
<td
|
||||
v-for="plan in pricingPlans"
|
||||
:key="plan.name"
|
||||
class="px-3 w-1/4 pt-1"
|
||||
:class="[
|
||||
plan.name === WorkspacePlans.Team
|
||||
? 'border-l border-r border-outline-3 bg-foundation-2'
|
||||
: '',
|
||||
plan.name === WorkspacePlans.Team &&
|
||||
index === Object.values(features).length - 1
|
||||
? 'pb-6 border-b rounded-b-lg'
|
||||
: ''
|
||||
]"
|
||||
>
|
||||
<div class="border-b border-outline-3 flex items-center px-3 min-h-[42px]">
|
||||
<CheckIcon
|
||||
v-if="plan.features.includes(feature.name)"
|
||||
class="w-3 h-3 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsWorkspacesBillingPricingTable_WorkspacePlanFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
WorkspacePlans,
|
||||
BillingInterval,
|
||||
WorkspacePlanStatuses
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
|
||||
import { CheckIcon } from '@heroicons/vue/24/outline'
|
||||
import { useBillingActions } from '~/lib/billing/composables/actions'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesBillingPricingTable_WorkspacePlan on WorkspacePlan {
|
||||
name
|
||||
status
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceId: string
|
||||
currentPlan: MaybeNullOrUndefined<SettingsWorkspacesBillingPricingTable_WorkspacePlanFragment>
|
||||
}>()
|
||||
|
||||
const { upgradePlanRedirect } = useBillingActions()
|
||||
|
||||
const pricingPlans = ref([
|
||||
pricingPlansConfig.plans[WorkspacePlans.Team],
|
||||
pricingPlansConfig.plans[WorkspacePlans.Pro],
|
||||
pricingPlansConfig.plans[WorkspacePlans.Business]
|
||||
])
|
||||
const features = ref(pricingPlansConfig.features)
|
||||
const isYearlyPlan = ref(false)
|
||||
|
||||
const onUpgradePlanClick = (plan: WorkspacePlans) => {
|
||||
upgradePlanRedirect({
|
||||
plan,
|
||||
cycle: isYearlyPlan.value ? BillingInterval.Yearly : BillingInterval.Monthly,
|
||||
workspaceId: props.workspaceId
|
||||
})
|
||||
}
|
||||
|
||||
const hasTrialPlan = computed(
|
||||
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
|
||||
)
|
||||
|
||||
const canUpgradeToPlan = (plan: WorkspacePlans) => {
|
||||
if (!props.currentPlan?.name) return false
|
||||
|
||||
const allowedUpgrades: Record<WorkspacePlans, WorkspacePlans[]> = {
|
||||
[WorkspacePlans.Team]: [WorkspacePlans.Pro, WorkspacePlans.Business],
|
||||
[WorkspacePlans.Pro]: [WorkspacePlans.Business],
|
||||
[WorkspacePlans.Business]: [],
|
||||
[WorkspacePlans.Academia]: [],
|
||||
[WorkspacePlans.Unlimited]: []
|
||||
}
|
||||
|
||||
return allowedUpgrades[props.currentPlan.name]?.includes(plan) ?? false
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<table class="w-full flex flex-col">
|
||||
<thead>
|
||||
<tr class="w-full flex">
|
||||
<th class="w-1/4 flex pl-5 pr-6 pt-4 pb-2 font-medium">
|
||||
<h4>Compare plans</h4>
|
||||
</th>
|
||||
<th
|
||||
v-for="plan in plans"
|
||||
:key="`desktop-${plan.name}`"
|
||||
class="w-1/4 px-6 pt-4 pb-2"
|
||||
:class="[
|
||||
plan.name === WorkspacePlans.Team
|
||||
? 'border border-b-0 border-outline-3 bg-foundation-2 rounded-t-lg'
|
||||
: ''
|
||||
]"
|
||||
scope="col"
|
||||
>
|
||||
<SettingsWorkspacesBillingPricingTableHeader
|
||||
:plan="plan"
|
||||
:is-yearly-plan="isYearlyPlan"
|
||||
:current-plan="currentPlan"
|
||||
:workspace-id="workspaceId"
|
||||
:is-admin="isAdmin"
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="w-full flex flex-col">
|
||||
<tr v-for="(feature, key, index) in features" :key="key" class="flex">
|
||||
<th
|
||||
class="font-normal text-foreground text-body-xs w-1/4 pr-3 pt-1"
|
||||
scope="row"
|
||||
>
|
||||
<div class="border-b border-outline-3 min-h-[42px] pl-5 flex items-center">
|
||||
{{ feature.name }}
|
||||
</div>
|
||||
</th>
|
||||
<td
|
||||
v-for="plan in plans"
|
||||
:key="plan.name"
|
||||
class="px-3 w-1/4 pt-1"
|
||||
:class="[
|
||||
plan.name === WorkspacePlans.Team
|
||||
? 'border-l border-r border-outline-3 bg-foundation-2'
|
||||
: '',
|
||||
plan.name === WorkspacePlans.Team &&
|
||||
index === Object.values(features).length - 1
|
||||
? 'pb-6 border-b rounded-b-lg'
|
||||
: ''
|
||||
]"
|
||||
>
|
||||
<div class="border-b border-outline-3 flex items-center px-3 min-h-[42px]">
|
||||
<CheckIcon
|
||||
v-if="plan.features.includes(feature.name as PlanFeaturesList)"
|
||||
class="w-3 h-3 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WorkspacePlan } from '~/lib/common/generated/gql/graphql'
|
||||
import { WorkspacePlans } from '~/lib/common/generated/gql/graphql'
|
||||
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
|
||||
import type { PlanFeaturesList } from '~/lib/billing/helpers/types'
|
||||
import { CheckIcon } from '@heroicons/vue/24/outline'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
defineProps<{
|
||||
isYearlyPlan: boolean
|
||||
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
|
||||
workspaceId: string
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const plans = ref(pricingPlansConfig.plans)
|
||||
const features = ref(pricingPlansConfig.features)
|
||||
</script>
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-y-1 font-normal">
|
||||
<h4 class="text-foreground text-body-xs">
|
||||
Workspace
|
||||
<span class="capitalize">{{ plan.name }}</span>
|
||||
</h4>
|
||||
<p class="text-foreground text-heading">
|
||||
£{{
|
||||
isYearlyPlan
|
||||
? plan.cost.yearly[Roles.Workspace.Member]
|
||||
: plan.cost.monthly[Roles.Workspace.Member]
|
||||
}}
|
||||
per seat/month
|
||||
</p>
|
||||
<p class="text-foreground-2 text-body-2xs pt-1">
|
||||
Billed {{ isYearlyPlan ? 'annually' : 'monthly' }}
|
||||
</p>
|
||||
<div class="w-full">
|
||||
<FormButton
|
||||
:color="plan.name === WorkspacePlans.Team ? 'primary' : 'outline'"
|
||||
:disabled="(!hasTrialPlan && !canUpgradeToPlan) || !isAdmin"
|
||||
class="mt-3"
|
||||
full-width
|
||||
@click="onUpgradePlanClick(plan.name)"
|
||||
>
|
||||
{{ hasTrialPlan ? 'Subscribe' : 'Upgrade' }} to
|
||||
<span class="capitalize">{{ plan.name }}</span>
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type PricingPlan } from '@/lib/billing/helpers/types'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import {
|
||||
type WorkspacePlan,
|
||||
WorkspacePlanStatuses,
|
||||
WorkspacePlans,
|
||||
BillingInterval
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useBillingActions } from '~/lib/billing/composables/actions'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
plan: PricingPlan
|
||||
isYearlyPlan: boolean
|
||||
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
|
||||
workspaceId: string
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const { upgradePlanRedirect } = useBillingActions()
|
||||
|
||||
const canUpgradeToPlan = computed(() => {
|
||||
if (!props.currentPlan) return false
|
||||
|
||||
const allowedUpgrades: Record<WorkspacePlans, WorkspacePlans[]> = {
|
||||
[WorkspacePlans.Team]: [WorkspacePlans.Pro, WorkspacePlans.Business],
|
||||
[WorkspacePlans.Pro]: [WorkspacePlans.Business],
|
||||
[WorkspacePlans.Business]: [],
|
||||
[WorkspacePlans.Academia]: [],
|
||||
[WorkspacePlans.Unlimited]: []
|
||||
}
|
||||
|
||||
return allowedUpgrades[props.currentPlan.name].includes(props.plan.name)
|
||||
})
|
||||
const hasTrialPlan = computed(
|
||||
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
|
||||
)
|
||||
|
||||
const onUpgradePlanClick = (plan: WorkspacePlans) => {
|
||||
upgradePlanRedirect({
|
||||
plan,
|
||||
cycle: props.isYearlyPlan ? BillingInterval.Yearly : BillingInterval.Monthly,
|
||||
workspaceId: props.workspaceId
|
||||
})
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="`mobile-${plan.name}`"
|
||||
class="border border-outline-3 bg-foundation rounded-lg p-4 pb-2"
|
||||
>
|
||||
<SettingsWorkspacesBillingPricingTableHeader
|
||||
:plan="plan"
|
||||
:is-yearly-plan="isYearlyPlan"
|
||||
:current-plan="currentPlan"
|
||||
:workspace-id="workspaceId"
|
||||
:is-admin="isAdmin"
|
||||
/>
|
||||
<ul class="flex flex-col gap-y-2 mt-6">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.name"
|
||||
class="flex items-center justify-between border-b last:border-b-0 border-outline-3 pb-2"
|
||||
>
|
||||
{{ feature.name }}
|
||||
<CheckIcon
|
||||
v-if="plan.features.includes(feature.name as PlanFeaturesList)"
|
||||
class="w-3 h-3 text-foreground"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WorkspacePlan } from '~/lib/common/generated/gql/graphql'
|
||||
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
|
||||
import type { PlanFeaturesList } from '~/lib/billing/helpers/types'
|
||||
import { CheckIcon } from '@heroicons/vue/24/outline'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
defineProps<{
|
||||
isYearlyPlan: boolean
|
||||
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
|
||||
workspaceId: string
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const plans = ref(pricingPlansConfig.plans)
|
||||
const features = ref(pricingPlansConfig.features)
|
||||
</script>
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-y-6">
|
||||
<div class="flex flex-col lg:flex-row justify-between gap-y-4">
|
||||
<SettingsSectionHeader
|
||||
:title="hasTrialPlan ? 'Start your subscription' : 'Upgrade your plan'"
|
||||
subheading
|
||||
/>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="text-foreground-3 text-body-xs">Save 20% with annual billing</p>
|
||||
<FormSwitch v-model="isYearlyPlan" :show-label="false" name="annual billing" />
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="isDesktop ? DesktopTable : MobileTable"
|
||||
:workspace-id="workspaceId"
|
||||
:current-plan="currentPlan"
|
||||
:is-yearly-plan="isYearlyPlan"
|
||||
:is-admin="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBreakpoints } from '@vueuse/core'
|
||||
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
|
||||
import {
|
||||
WorkspacePlanStatuses,
|
||||
type WorkspacePlan
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesBillingPricingTable_WorkspacePlan on WorkspacePlan {
|
||||
name
|
||||
status
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceId: string
|
||||
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const breakpoints = useBreakpoints(TailwindBreakpoints)
|
||||
|
||||
const DesktopTable = defineAsyncComponent(
|
||||
() => import('@/components/settings/workspaces/billing/PricingTable/Desktop.vue')
|
||||
)
|
||||
const MobileTable = defineAsyncComponent(
|
||||
() => import('@/components/settings/workspaces/billing/PricingTable/Mobile.vue')
|
||||
)
|
||||
const isDesktop = breakpoints.greaterOrEqual('lg')
|
||||
const isYearlyPlan = ref(false)
|
||||
|
||||
const hasTrialPlan = computed(
|
||||
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
|
||||
)
|
||||
</script>
|
|
@ -127,7 +127,7 @@ import {
|
|||
SettingMenuKeys,
|
||||
type AvailableSettingsMenuKeys
|
||||
} from '~/lib/settings/helpers/types'
|
||||
|
||||
import { useBillingActions } from '~/lib/billing/composables/actions'
|
||||
graphql(`
|
||||
fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {
|
||||
totalCount
|
||||
|
@ -138,6 +138,7 @@ graphql(`
|
|||
}
|
||||
`)
|
||||
|
||||
const { validateCheckoutSession } = useBillingActions()
|
||||
const { workspaceMixpanelUpdateGroup } = useWorkspacesMixpanel()
|
||||
const areQueriesLoading = useQueryLoading()
|
||||
const route = useRoute()
|
||||
|
@ -280,6 +281,7 @@ onResult((queryResult) => {
|
|||
useHeadSafe({
|
||||
title: queryResult.data.workspaceBySlug.name
|
||||
})
|
||||
validateCheckoutSession(queryResult.data.workspaceBySlug.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -5,10 +5,15 @@ import type {
|
|||
BillingInterval
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { settingsBillingCancelCheckoutSessionMutation } from '~/lib/settings/graphql/mutations'
|
||||
import { WorkspacePlanStatuses } from '~/lib/common/generated/gql/graphql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
|
||||
export const useBillingActions = () => {
|
||||
const mixpanel = useMixpanel()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { client: apollo } = useApolloClient()
|
||||
const { mutate: cancelCheckoutSessionMutation } = useMutation(
|
||||
settingsBillingCancelCheckoutSessionMutation
|
||||
|
@ -58,9 +63,35 @@ export const useBillingActions = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const validateCheckoutSession = (workspaceId: string) => {
|
||||
const sessionIdQuery = route.query?.session_id
|
||||
const paymentStatusQuery = route.query?.payment_status
|
||||
|
||||
if (sessionIdQuery && paymentStatusQuery) {
|
||||
if (paymentStatusQuery === WorkspacePlanStatuses.Canceled) {
|
||||
cancelCheckoutSession(String(sessionIdQuery), workspaceId)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Your payment was canceled'
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Your workspace plan was successfully updated'
|
||||
})
|
||||
}
|
||||
|
||||
const currentQueryParams = { ...route.query }
|
||||
delete currentQueryParams.session_id
|
||||
delete currentQueryParams.payment_status
|
||||
router.push({ query: currentQueryParams })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
billingPortalRedirect,
|
||||
upgradePlanRedirect,
|
||||
cancelCheckoutSession
|
||||
cancelCheckoutSession,
|
||||
validateCheckoutSession
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,6 @@
|
|||
import { WorkspacePlans, BillingInterval } from '~/lib/common/generated/gql/graphql'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
enum PlanFeaturesList {
|
||||
Workspaces = 'Workspaces',
|
||||
RoleManagement = 'Role management',
|
||||
GuestUsers = 'Guest users',
|
||||
PrivateAutomateFunctions = 'Private automate functions',
|
||||
DomainSecurity = 'Domain security',
|
||||
SSO = 'Single Sign-On (SSO)',
|
||||
CustomerDataRegion = 'Customer data region',
|
||||
PrioritySupport = 'Priority support'
|
||||
}
|
||||
import { PlanFeaturesList, type PricingPlan } from '@/lib/billing/helpers/types'
|
||||
|
||||
const baseFeatures = [
|
||||
PlanFeaturesList.Workspaces,
|
||||
|
@ -20,7 +10,13 @@ const baseFeatures = [
|
|||
PlanFeaturesList.DomainSecurity
|
||||
]
|
||||
|
||||
export const pricingPlansConfig = {
|
||||
export const pricingPlansConfig: {
|
||||
features: Record<PlanFeaturesList, { name: string; description: string }>
|
||||
plans: Record<
|
||||
WorkspacePlans.Team | WorkspacePlans.Pro | WorkspacePlans.Business,
|
||||
PricingPlan
|
||||
>
|
||||
} = {
|
||||
features: {
|
||||
[PlanFeaturesList.Workspaces]: {
|
||||
name: PlanFeaturesList.Workspaces,
|
||||
|
@ -46,8 +42,8 @@ export const pricingPlansConfig = {
|
|||
name: PlanFeaturesList.SSO,
|
||||
description: ''
|
||||
},
|
||||
[PlanFeaturesList.CustomerDataRegion]: {
|
||||
name: PlanFeaturesList.CustomerDataRegion,
|
||||
[PlanFeaturesList.CustomDataRegion]: {
|
||||
name: PlanFeaturesList.CustomDataRegion,
|
||||
description: ''
|
||||
},
|
||||
[PlanFeaturesList.PrioritySupport]: {
|
||||
|
@ -93,7 +89,7 @@ export const pricingPlansConfig = {
|
|||
features: [
|
||||
...baseFeatures,
|
||||
PlanFeaturesList.SSO,
|
||||
PlanFeaturesList.CustomerDataRegion,
|
||||
PlanFeaturesList.CustomDataRegion,
|
||||
PlanFeaturesList.PrioritySupport
|
||||
],
|
||||
cost: {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import type {
|
||||
BillingInterval,
|
||||
WorkspacePlans
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import type { WorkspaceRoles } from '@speckle/shared'
|
||||
|
||||
export enum PlanFeaturesList {
|
||||
Workspaces = 'Workspaces',
|
||||
RoleManagement = 'Role management',
|
||||
GuestUsers = 'Guest users',
|
||||
PrivateAutomateFunctions = 'Private automate functions',
|
||||
DomainSecurity = 'Domain security',
|
||||
SSO = 'Single Sign-On (SSO)',
|
||||
CustomDataRegion = 'Custom data region',
|
||||
PrioritySupport = 'Priority support'
|
||||
}
|
||||
|
||||
export type PricingPlan = {
|
||||
name: WorkspacePlans
|
||||
features: PlanFeaturesList[]
|
||||
cost: {
|
||||
[I in BillingInterval]: Record<WorkspaceRoles, number>
|
||||
}
|
||||
}
|
|
@ -114,7 +114,7 @@ const documents = {
|
|||
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
|
||||
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
|
||||
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n defaultLogoIndex\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
|
||||
|
@ -770,7 +770,7 @@ export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User o
|
|||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -16,7 +16,7 @@ import { once } from 'events'
|
|||
import type http from 'http'
|
||||
import type express from 'express'
|
||||
import type net from 'net'
|
||||
import { MaybeAsync, MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { MaybeAsync, MaybeNullOrUndefined, wait } from '@speckle/shared'
|
||||
import type mocha from 'mocha'
|
||||
import {
|
||||
getAvailableRegionKeysFactory,
|
||||
|
@ -126,15 +126,17 @@ export const resetPubSubFactory = (deps: { db: Knex }) => async () => {
|
|||
const ensureAivenExtras = ensureAivenExtrasFactory(deps)
|
||||
await ensureAivenExtras()
|
||||
|
||||
type SubInfo = {
|
||||
subname: string
|
||||
subconninfo: string
|
||||
subpublications: string[]
|
||||
subslotname: string
|
||||
}
|
||||
|
||||
const subscriptions = (await deps.db.raw(
|
||||
`SELECT subname, subconninfo, subpublications, subslotname FROM aiven_extras.pg_list_all_subscriptions() WHERE subname ILIKE 'test_%';`
|
||||
)) as {
|
||||
rows: Array<{
|
||||
subname: string
|
||||
subconninfo: string
|
||||
subpublications: string[]
|
||||
subslotname: string
|
||||
}>
|
||||
rows: Array<SubInfo>
|
||||
}
|
||||
const publications = (await deps.db.raw(
|
||||
`SELECT pubname FROM pg_publication WHERE pubname ILIKE 'test_%';`
|
||||
|
@ -142,22 +144,24 @@ export const resetPubSubFactory = (deps: { db: Knex }) => async () => {
|
|||
rows: Array<{ pubname: string }>
|
||||
}
|
||||
|
||||
// Drop all subs
|
||||
for (const sub of subscriptions.rows) {
|
||||
// Running serially, otherwise some kind of race condition issue can pop up
|
||||
const dropSubs = async (info: SubInfo) => {
|
||||
await deps.db.raw(
|
||||
`SELECT * FROM aiven_extras.pg_alter_subscription_disable('${sub.subname}');`
|
||||
`SELECT * FROM aiven_extras.pg_alter_subscription_disable('${info.subname}');`
|
||||
)
|
||||
await wait(500)
|
||||
await deps.db.raw(
|
||||
`SELECT * FROM aiven_extras.pg_drop_subscription('${sub.subname}');`
|
||||
`SELECT * FROM aiven_extras.pg_drop_subscription('${info.subname}');`
|
||||
)
|
||||
await wait(1000)
|
||||
await deps.db.raw(
|
||||
`SELECT * FROM aiven_extras.dblink_slot_create_or_drop('${info.subconninfo}', '${info.subslotname}', 'drop');`
|
||||
)
|
||||
|
||||
// TODO: Causes flaky test breakages, maybe we dont need it? ("error: replication slot "test_userssub_region1" is active for PID 2294")
|
||||
// await deps.db.raw(
|
||||
// `SELECT * FROM aiven_extras.dblink_slot_create_or_drop('${sub.subconninfo}', '${sub.subslotname}', 'drop');`
|
||||
// )
|
||||
}
|
||||
|
||||
// Drop all subs
|
||||
// (concurrently, cause it seems possible and we have those delays there)
|
||||
await Promise.all(subscriptions.rows.map(dropSubs))
|
||||
|
||||
// Drop all pubs
|
||||
for (const pub of publications.rows) {
|
||||
await deps.db.raw(`DROP PUBLICATION ${pub.pubname};`)
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
>
|
||||
<slot name="title-icon"></slot>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<h6 class="font-semibold text-foreground-2 truncate text-body-2xs">
|
||||
<div class="flex flex-1 items-center justify-between truncate">
|
||||
<h6 class="font-semibold text-foreground-2 truncate text-body-2xs pr-2">
|
||||
{{ title }}
|
||||
</h6>
|
||||
<CommonBadge v-if="tag" rounded>
|
||||
|
@ -33,8 +33,8 @@
|
|||
<div v-if="$slots['title-icon']" class="flex items-center justify-center">
|
||||
<slot name="title-icon"></slot>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<h6 class="font-semibold text-foreground-2 truncate text-body-2xs">
|
||||
<div class="flex flex-1 items-center justify-between truncate">
|
||||
<h6 class="font-semibold text-foreground-2 truncate text-body-2xs pr-2">
|
||||
{{ title }}
|
||||
</h6>
|
||||
<CommonBadge v-if="tag" rounded>
|
||||
|
|
Загрузка…
Ссылка в новой задаче