diff --git a/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt index b7fd11c41f..fb14b5c56a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt @@ -16,6 +16,8 @@ import org.springframework.stereotype.Component import org.springframework.transaction.PlatformTransactionManager import java.sql.Timestamp import java.time.Duration +import java.time.LocalDate +import java.time.ZoneId.systemDefault import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAccessor @@ -84,6 +86,11 @@ class CurrentDateProvider( return forcedDate ?: Date() } + val localDate: LocalDate + get() { + return date.toInstant().atZone(systemDefault()).toLocalDate() + } + override fun getNow(): Optional { return Optional.of(date.toInstant()) } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt b/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt index d93dfd0c41..01fa4fe9cb 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt @@ -3,6 +3,7 @@ package io.tolgee.component import io.tolgee.util.Logging import io.tolgee.util.logger import jakarta.annotation.PreDestroy +import org.springframework.scheduling.support.CronTrigger import org.springframework.stereotype.Component import java.time.Duration import java.util.* @@ -46,6 +47,19 @@ class SchedulingManager( return id } + fun scheduleWithCron( + runnable: Runnable, + cron: String, + ): String { + val future = taskScheduler.schedule(runnable, CronTrigger(cron)) + if (future == null) { + throw IllegalStateException("Future from scheduler was null") + } + val id = UUID.randomUUID().toString() + scheduledTasks[id] = future + return id + } + @PreDestroy fun cancelAll() { Companion.cancelAll() diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index d792655277..c0f635d17e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -311,6 +311,8 @@ enum class Message { IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED, ALREADY_IMPERSONATING_USER, OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE, + PLAN_MIGRATION_NOT_FOUND, + PLAN_HAS_MIGRATIONS, ; val code: String diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index 713a3522a8..ab91496f37 100644 --- a/backend/data/src/main/resources/I18n_en.properties +++ b/backend/data/src/main/resources/I18n_en.properties @@ -113,3 +113,24 @@ notifications.email.security-settings-link=Check your security settings \ +
\ +Earlier this year, we introduced a new pricing structure for new Tolgee customers while keeping existing users on their current plans. We’re now moving all subscriptions to the new plans to make things simpler and fair for everyone.
\ +
\ +In the new structure, translation strings have been replaced with keys and seats to better reflect how Tolgee is used. You can view the updated pricing here
https://tolgee.io/pricing.
\ +
\ +Your current {1} plan will automatically switch to the new {2} plan at your next renewal, whether monthly or yearly, depending on your subscription. If you’d like to explore other subscriptions options, you can do so anytime in the Subscriptions section within the Tolgee platform.
\ +
\ +Thank you for your continued trust and support!
\ +
\ +P.S. I understand these changes may cause discomfort and not accommodate everyone’s circumstances. If this materially impacts your business, I warmly invite you to contact me directly for a personal dialogue regarding your concerns. You can reach me at:
\ +\ +Warm regards,
\ +Jan
\ +CEO, Tolgee diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/PropertiesController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/PropertiesController.kt index 12ddab1dbd..20eb67258c 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/PropertiesController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/PropertiesController.kt @@ -1,22 +1,17 @@ package io.tolgee.controllers.internal -import io.tolgee.configuration.tolgee.E2eRuntimeMutable import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.request.SetPropertyDto -import io.tolgee.exceptions.BadRequestException -import io.tolgee.exceptions.NotFoundException +import io.tolgee.facade.InternalPropertiesSetterFacade import jakarta.validation.Valid import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody -import kotlin.reflect.KMutableProperty1 -import kotlin.reflect.KProperty1 -import kotlin.reflect.full.declaredMemberProperties -import kotlin.reflect.full.hasAnnotation @InternalController(["internal/properties"]) class PropertiesController( - val tolgeeProperties: TolgeeProperties, + private val tolgeeProperties: TolgeeProperties, + private val internalPropertiesSetterFacade: InternalPropertiesSetterFacade, ) { @PutMapping(value = ["/set"]) @Transactional @@ -24,24 +19,6 @@ class PropertiesController( @RequestBody @Valid setPropertyDto: SetPropertyDto, ) { - val name = setPropertyDto.name - var instance: Any = tolgeeProperties - name.split(".").let { namePath -> - namePath.forEachIndexed { idx, property -> - val isLast = idx == namePath.size - 1 - val props = instance::class.declaredMemberProperties - val prop = props.find { it.name == property } ?: throw NotFoundException() - if (isLast) { - (prop as? KMutableProperty1)?.let { - if (!it.hasAnnotation()) { - io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE - } - it.set(instance, setPropertyDto.value) - return - } ?: throw BadRequestException(io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE) - } - instance = (prop as KProperty1).get(instance)!! - } - } + internalPropertiesSetterFacade.setProperty(tolgeeProperties, setPropertyDto) } } diff --git a/backend/development/src/main/kotlin/io/tolgee/facade/InternalPropertiesSetterFacade.kt b/backend/development/src/main/kotlin/io/tolgee/facade/InternalPropertiesSetterFacade.kt new file mode 100644 index 0000000000..eb370a11ae --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/facade/InternalPropertiesSetterFacade.kt @@ -0,0 +1,42 @@ +package io.tolgee.facade + +import io.tolgee.configuration.tolgee.E2eRuntimeMutable +import io.tolgee.dtos.request.SetPropertyDto +import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.NotFoundException +import org.springframework.stereotype.Component +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.hasAnnotation + +@Component +class InternalPropertiesSetterFacade { + fun setProperty( + root: Any, + setPropertyDto: SetPropertyDto, + onSet: (() -> Unit)? = null, + ) { + val name = setPropertyDto.name + var instance: Any = root + name.split(".").let { namePath -> + namePath.forEachIndexed { idx, property -> + val isLast = idx == namePath.size - 1 + val props = instance::class.declaredMemberProperties + val prop = props.find { it.name == property } ?: throw NotFoundException() + if (isLast) { + (prop as? KMutableProperty1)?.let { + if (!it.hasAnnotation()) { + io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE + } + it.set(instance, setPropertyDto.value) + onSet?.invoke() + return + } ?: throw BadRequestException(io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE) + } + instance = (prop as KProperty1).get(instance) + ?: throw NotFoundException() + } + } + } +} diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt index e8421d0cdd..ab72d76836 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt @@ -66,6 +66,10 @@ class EmailTestUtil() { verify(javaMailSender).send(any()) } + fun verifyTimesEmailSent(num: Int) { + verify(javaMailSender, times(num)).send(any()) + } + val assertEmailTo: AbstractStringAssert<*> get() { @Suppress("CAST_NEVER_SUCCEEDS") diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 26c61b1fa7..7107b39b32 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -80,6 +80,9 @@ declare namespace DataCy { "administration-plan-field-stripe-product" | "administration-plan-field-stripe-product-name" | "administration-plan-selector" | + "administration-plans-create-migration" | + "administration-plans-edit-migration" | + "administration-plans-item-is-migrating-badge" | "administration-subscriptions-active-self-hosted-ee-plan" | "administration-subscriptions-assign-plan-save-button" | "administration-subscriptions-cloud-plan-name" | @@ -263,6 +266,7 @@ declare namespace DataCy { "create-task-submit" | "dashboard-projects-list-item" | "default-namespace-select" | + "delete-plan-migration-button" | "delete-user-button" | "developer-menu-content-delivery" | "developer-menu-storage" | @@ -558,6 +562,7 @@ declare namespace DataCy { "permissions-menu-save" | "plan-limit-dialog-close" | "plan-limit-exceeded-popover" | + "plan-migration-tooltip-detail" | "plan_seat_limit_exceeded_while_accepting_invitation_message" | "project-ai-prompt-dialog-description-input" | "project-ai-prompt-dialog-save" | @@ -677,6 +682,7 @@ declare namespace DataCy { "signup-error-free-seat-limit" | "signup-error-plan-seat-limit" | "signup-error-seats-spending-limit" | + "source-plan-selector" | "spending-limit-dialog-close" | "spending-limit-exceeded-popover" | "sso-migration-info-text" | @@ -708,6 +714,7 @@ declare namespace DataCy { "suggestions-list" | "tag-autocomplete-input" | "tag-autocomplete-option" | + "target-plan-selector" | "task-date-picker" | "task-detail" | "task-detail-author" | diff --git a/webapp/src/component/common/FullWidthTooltip.tsx b/webapp/src/component/common/FullWidthTooltip.tsx new file mode 100644 index 0000000000..aa3e8f0b7c --- /dev/null +++ b/webapp/src/component/common/FullWidthTooltip.tsx @@ -0,0 +1,11 @@ +import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material'; + +export const FullWidthTooltip = styled( + ({ className, ...props }: TooltipProps) => ( + + ) +)({ + [`& .${tooltipClasses.tooltip}`]: { + maxWidth: 'none', + }, +}); diff --git a/webapp/src/component/common/table/PaginatedHateoasTable.tsx b/webapp/src/component/common/table/PaginatedHateoasTable.tsx index 9d2529683c..f24e286619 100644 --- a/webapp/src/component/common/table/PaginatedHateoasTable.tsx +++ b/webapp/src/component/common/table/PaginatedHateoasTable.tsx @@ -1,4 +1,4 @@ -import React, { FC, JSXElementConstructor } from 'react'; +import React, { FC, JSXElementConstructor, ReactNode } from 'react'; import { HateoasListData, HateoasPaginatedData, @@ -8,7 +8,7 @@ import { PaginatedHateoasList, PaginatedHateoasListProps, } from '../list/PaginatedHateoasList'; -import { Table, TableBody } from '@mui/material'; +import { Table, TableBody, TableHead } from '@mui/material'; export type PaginatedHateoasTableProps< WrapperComponent extends @@ -19,7 +19,9 @@ export type PaginatedHateoasTableProps< > = Omit< PaginatedHateoasListProps, 'listComponent' ->; +> & { + tableHead?: ReactNode; +}; export const PaginatedHateoasTable = < WrapperComponent extends @@ -30,17 +32,31 @@ export const PaginatedHateoasTable = < >( props: PaginatedHateoasTableProps ) => { + const { tableHead, ...rest } = props; return ( ( + + )} + {...rest} /> ); }; -const PaginatedHateoasTableListComponent: FC = ({ children }) => { +interface PaginatedHateoasTableListComponentProps { + children: ReactNode; + tableHead?: ReactNode; +} + +const PaginatedHateoasTableListComponent: FC< + PaginatedHateoasTableListComponentProps +> = ({ children, tableHead }) => { return ( + {tableHead && {tableHead}} {children}
); diff --git a/webapp/src/component/layout/HeaderBar.tsx b/webapp/src/component/layout/HeaderBar.tsx index 621316ff6c..c1c4b19983 100644 --- a/webapp/src/component/layout/HeaderBar.tsx +++ b/webapp/src/component/layout/HeaderBar.tsx @@ -32,6 +32,7 @@ export type HeaderBarProps = { switcher?: ReactNode; maxWidth?: BaseViewWidth; initialSearch?: string; + customButtons?: ReactNode[]; }; export const HeaderBar: React.VFC = (props) => { @@ -81,6 +82,12 @@ export const HeaderBar: React.VFC = (props) => { {props.switcher} )} + {props.customButtons && + props.customButtons.map((button, index) => ( + + {button} + + ))} {props.addComponent ? props.addComponent : (props.onAdd || props.addLinkTo) && ( diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index cf44a5c4c8..d5b6aa2629 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -39,6 +39,14 @@ Yup.setLocale({ /> ), }, + number: { + min: ({ min }) => ( + + ), + }, }); export class Validation { @@ -521,6 +529,12 @@ export class Validation { .required() .matches(/^#[0-9A-F]{6}$/i, t('validation_invalid_hex_color')), }); + + static readonly PLAN_MIGRATION_FORM = () => + Yup.object().shape({ + monthlyOffsetDays: Yup.number().required().min(0), + yearlyOffsetDays: Yup.number().required().min(0), + }); } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 940d36bb1f..9f0a3f5e6f 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -63,6 +63,7 @@ export enum PARAMS { TRANSLATION_ID = 'translationId', PLAN_ID = 'planId', TA_ID = 'taId', + PLAN_MIGRATION_ID = 'migrationId', } export class LINKS { @@ -246,6 +247,26 @@ export class LINKS { 'create' ); + static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS, + 'create-migration' + ); + + static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS, + 'migration/' + p(PARAMS.PLAN_MIGRATION_ID) + ); + + static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_CREATE = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_EE_PLANS, + 'create-migration' + ); + + static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_EDIT = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_EE_PLANS, + 'migration/' + p(PARAMS.PLAN_MIGRATION_ID) + ); + /** * Organizations */ diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CloudPlanEditPlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CloudPlanEditPlanMigrationForm.tsx new file mode 100644 index 0000000000..8de7e6565d --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CloudPlanEditPlanMigrationForm.tsx @@ -0,0 +1,59 @@ +import { PlanMigrationFormData } from './PlanMigrationForm'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { EditPlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm'; +import React from 'react'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; + +type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; + +type Props = { + migration: CloudPlanMigrationModel; + onSubmit: () => void; + onDelete?: () => void; +}; + +export const CloudPlanEditPlanMigrationForm = ({ + migration, + onSubmit, + onDelete, +}: Props) => { + const updateLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'put', + }); + + const deleteLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'delete', + }); + + const remove = (migrationId: number) => { + deleteLoadable.mutate( + { path: { migrationId } }, + { + onSuccess: onDelete, + } + ); + }; + + const submit = (values: PlanMigrationFormData) => { + updateLoadable.mutate( + { + path: { migrationId: migration.id }, + content: { 'application/json': values }, + }, + { + onSuccess: onSubmit, + } + ); + }; + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx new file mode 100644 index 0000000000..7b2c02aa51 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx @@ -0,0 +1,28 @@ +import { + CreatePlanMigrationFormData, + PlanMigrationForm, +} from './PlanMigrationForm'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; + +const emptyDefaultValues: CreatePlanMigrationFormData = { + enabled: true, + sourcePlanId: 0, + targetPlanId: 0, + monthlyOffsetDays: 14, + yearlyOffsetDays: 30, +}; + +type Props = { + onSubmit: (values: CreatePlanMigrationFormData) => void; + loading?: boolean; + planType?: PlanType; +}; + +export const CreatePlanMigrationForm: React.FC = (props) => { + return ( + + defaultValues={emptyDefaultValues} + {...props} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx new file mode 100644 index 0000000000..969804a9df --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx @@ -0,0 +1,32 @@ +import { PlanMigrationForm, PlanMigrationFormData } from './PlanMigrationForm'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; + +type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; +type SelfHostedEePlanMigrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +type Props = { + onSubmit: (values: PlanMigrationFormData) => void; + loading?: boolean; + onDelete?: (id: number) => void; + migration: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; + planType?: PlanType; +}; + +export const EditPlanMigrationForm: React.FC = (props) => { + const { migration } = props; + const initialValues: PlanMigrationFormData = { + enabled: migration.enabled, + sourcePlanFree: migration.sourcePlan.free, + targetPlanId: migration.targetPlan.id, + monthlyOffsetDays: migration.monthlyOffsetDays, + yearlyOffsetDays: migration.yearlyOffsetDays, + }; + return ( + + defaultValues={initialValues} + {...props} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx new file mode 100644 index 0000000000..c50cfdd4d1 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -0,0 +1,201 @@ +import { Form, Formik } from 'formik'; +import { Box, Button, InputAdornment, Typography } from '@mui/material'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import React, { useState } from 'react'; +import { T, useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { PlanSelectorField } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { Switch } from 'tg.component/common/form/fields/Switch'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; +import { confirmation } from 'tg.hooks/confirmation'; +import { LabelHint } from 'tg.component/common/LabelHint'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { ChevronRight } from '@untitled-ui/icons-react'; + +type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; +type SelfHostedEePlanMigrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +type Props = { + defaultValues: T; + onSubmit: (value: T) => void; + onDelete?: (id: number) => void; + planType?: PlanType; + migration?: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; + loading?: boolean; +}; + +export type PlanMigrationFormData = + components['schemas']['PlanMigrationRequest'] & { + sourcePlanFree: boolean; + }; + +export type CreatePlanMigrationFormData = + components['schemas']['CreatePlanMigrationRequest']; + +type FormPlanType = { + id: number; + free?: boolean; +}; + +export const PlanMigrationForm = < + T extends CreatePlanMigrationFormData | PlanMigrationFormData +>({ + defaultValues, + onSubmit, + loading, + onDelete, + migration, + planType = 'cloud', +}: Props) => { + const { t } = useTranslate(); + const isUpdate = migration != null; + + const defaultSourcePlan = migration + ? { + id: migration.sourcePlan.id, + free: migration.sourcePlan.free, + } + : undefined; + + const [selectedSourcePlan, setSelectedSourcePlan] = useState< + FormPlanType | undefined + >(defaultSourcePlan); + + const [selectedTargetPlan, setSelectedTargetPlan] = useState({ + id: defaultValues.targetPlanId, + }); + + const initValues = { + ...defaultValues, + ...(isUpdate && + migration && { + sourcePlanId: migration.sourcePlan.id, + }), + }; + + return ( + + initialValues={initValues} + enableReinitialize + onSubmit={onSubmit} + validationSchema={Validation.PLAN_MIGRATION_FORM} + > +
+ + + + + { + setSelectedSourcePlan(plan); + }} + planProps={{ + hiddenIds: [selectedTargetPlan.id], + }} + filterHasMigration={false} + type={planType} + {...(isUpdate && { plans: [migration.sourcePlan] })} + /> + + setSelectedTargetPlan(plan)} + type={planType} + planProps={ + selectedSourcePlan && { + hiddenIds: [selectedSourcePlan.id], + free: selectedSourcePlan.free, + } + } + /> + + + + + {t('administration_plan_migration_run_configuration')} + + + + + + + {t('global_days')} + + ), + }} + /> + + {t('global_days')} + + ), + }} + /> + + + + {migration && isUpdate && ( + + )} + + + {isUpdate ? t('global_form_save') : t('global_form_create')} + + +
+ + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/SelfHostedEePlanEditPlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/SelfHostedEePlanEditPlanMigrationForm.tsx new file mode 100644 index 0000000000..c674381fcc --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/SelfHostedEePlanEditPlanMigrationForm.tsx @@ -0,0 +1,61 @@ +import { PlanMigrationFormData } from './PlanMigrationForm'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { EditPlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm'; +import React from 'react'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; + +type SelfHostedEePlanMigrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +type Props = { + migration: SelfHostedEePlanMigrationModel; + onSubmit: () => void; + onDelete?: () => void; +}; + +export const SelfHostedEePlanEditPlanMigrationForm = ({ + migration, + onSubmit, + onDelete, +}: Props) => { + const updateLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'put', + }); + + const deleteLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'delete', + }); + + const remove = (migrationId: number) => { + deleteLoadable.mutate( + { path: { migrationId } }, + { + onSuccess: onDelete, + } + ); + }; + + const submit = (values: PlanMigrationFormData) => { + updateLoadable.mutate( + { + path: { migrationId: migration.id }, + content: { 'application/json': values }, + }, + { + onSuccess: onSubmit, + } + ); + }; + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx new file mode 100644 index 0000000000..addc6852ec --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx @@ -0,0 +1,31 @@ +import { useFormikContext } from 'formik'; +import { CloudPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector'; +import { PlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { GenericPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector'; +import { SelfHostedEePlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/selfHostedEe/fields/SelfHostedEePlanSelector'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; + +export const PlanSelectorField = ({ + name, + type = 'cloud', + filterHasMigration, + ...props +}: { + name: string; + type?: PlanType; + filterHasMigration?: boolean; +} & Omit, 'onChange'>) => { + const { setFieldValue, values } = useFormikContext(); + + const Selector = + type === 'cloud' ? CloudPlanSelector : SelfHostedEePlanSelector; + + return ( + setFieldValue(name, value)} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/types.ts b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/types.ts new file mode 100644 index 0000000000..b9844c7a6c --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/types.ts @@ -0,0 +1 @@ +export type PlanType = 'cloud' | 'self-hosted'; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx index a4c00208ed..061b09ab57 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx @@ -12,13 +12,15 @@ export const CloudPlanSelector: FC< organizationId?: number; selectProps?: React.ComponentProps[`SelectProps`]; filterPublic?: boolean; + filterHasMigration?: boolean; } -> = ({ organizationId, filterPublic, ...props }) => { +> = ({ organizationId, filterPublic, filterHasMigration, ...props }) => { const plansLoadable = useBillingApiQuery({ url: '/v2/administration/billing/cloud-plans', method: 'get', query: { filterPublic, + filterHasMigration, }, }); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx index 2308ab2338..89d4617314 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx @@ -6,15 +6,22 @@ import React from 'react'; import { T } from '@tolgee/react'; import { Box } from '@mui/material'; -type GenericPlanType = { id: number; name: string }; +export type GenericPlanType = { id: number; name: string; free: boolean }; + +type PlansProps = { + hiddenIds?: number[]; + free?: boolean; +}; export interface GenericPlanSelector { organizationId?: number; - onPlanChange?: (planId: T) => void; + onPlanChange?: (plan: T) => void; value?: number; onChange?: (value: number) => void; selectProps?: React.ComponentProps[`SelectProps`]; plans?: T[]; + planProps?: PlansProps; + dataCy?: string; } export const GenericPlanSelector = ({ @@ -23,6 +30,8 @@ export const GenericPlanSelector = ({ selectProps, onPlanChange, plans, + planProps, + dataCy = 'administration-plan-selector', }: GenericPlanSelector) => { if (!plans) { return ( @@ -32,13 +41,23 @@ export const GenericPlanSelector = ({ ); } - const selectItems = plans.map( - (plan) => - ({ - value: plan.id, - name: plan.name, - } satisfies SelectItem) - ); + const selectItems = plans + .filter((plan) => { + if (planProps?.hiddenIds?.includes(plan.id)) { + return false; + } + if (planProps?.free !== undefined) { + return planProps.free === plan.free; + } + return true; + }) + .map( + (plan) => + ({ + value: plan.id, + name: plan.name, + } satisfies SelectItem) + ); function handleChange(planId: number) { if (plans) { @@ -56,7 +75,7 @@ export const GenericPlanSelector = ({ return ( , 'plans' - > & { organizationId?: number } -> = ({ organizationId, ...props }) => { + > & { organizationId?: number; filterHasMigration?: boolean } +> = ({ organizationId, filterHasMigration, ...props }) => { const plansLoadable = useBillingApiQuery({ url: '/v2/administration/billing/self-hosted-ee-plans', method: 'get', query: { filterAssignableToOrganization: organizationId, + filterHasMigration, }, }); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx new file mode 100644 index 0000000000..d7200071a9 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx @@ -0,0 +1,47 @@ +import { useTranslate } from '@tolgee/react'; +import { LINKS } from 'tg.constants/links'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { AdministrationPlanMigrationCreateBase } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationCreateBase'; +import { CreatePlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; + +export const AdministrationCloudPlanMigrationCreate = () => { + const { t } = useTranslate(); + + const createMutation = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration', + method: 'post', + }); + + const onSubmit = ( + values: CreatePlanMigrationFormData, + callbacks: { onSuccess: () => void } + ) => { + createMutation.mutate( + { + content: { 'application/json': values }, + }, + { + onSuccess: callbacks.onSuccess, + } + ); + }; + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx new file mode 100644 index 0000000000..07e49070ba --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { AdministrationPlanMigrationEditBase } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationEditBase'; +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { CloudPlanEditPlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/CloudPlanEditPlanMigrationForm'; + +export const AdministrationCloudPlanMigrationEdit = () => { + const { t } = useTranslate(); + const match = useRouteMatch(); + const migrationId = Number(match.params[PARAMS.PLAN_MIGRATION_ID]); + const history = useHistory(); + + if (isNaN(migrationId)) { + history.replace(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); + } + + const [subscriptionsPage, setSubscriptionsPage] = useState(0); + + const migrations = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'get', + path: { migrationId }, + }); + + const subscriptions = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}/subscriptions', + method: 'get', + path: { migrationId }, + query: { + page: subscriptionsPage, + size: 10, + }, + options: { + keepPreviousData: true, + }, + }); + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationCreateBase.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationCreateBase.tsx new file mode 100644 index 0000000000..b2636c6018 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationCreateBase.tsx @@ -0,0 +1,68 @@ +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; +import { Link } from 'tg.constants/links'; +import { Box, Typography } from '@mui/material'; +import { CreatePlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm'; +import React from 'react'; +import { T, useTranslate } from '@tolgee/react'; +import { CreatePlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory } from 'react-router-dom'; +import { NavigationItem } from 'tg.component/navigation/Navigation'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; + +type Props = { + onSubmit: ( + data: CreatePlanMigrationFormData, + callbacks: { onSuccess: () => void } + ) => void; + navigation: NavigationItem[]; + successLink: Link; + isLoading: boolean; + planType?: PlanType; +}; + +export const AdministrationPlanMigrationCreateBase = ({ + onSubmit, + navigation, + successLink, + isLoading, + planType, +}: Props) => { + const { t } = useTranslate(); + const messaging = useMessage(); + const history = useHistory(); + + const submit = (values: CreatePlanMigrationFormData) => { + onSubmit(values, { + onSuccess: () => { + messaging.success( + + ); + history.push(successLink.build()); + }, + }); + }; + + return ( + + + + + {t('administration_plan_migration_configure')} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationEditBase.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationEditBase.tsx new file mode 100644 index 0000000000..0eb56e22e5 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationEditBase.tsx @@ -0,0 +1,94 @@ +import { Box, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; +import { useHistory } from 'react-router-dom'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { PlanMigrationRecordList } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/PlanMigrationRecordList'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { UseQueryResult } from 'react-query'; +import { HateoasListData } from 'tg.service/response.types'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { NavigationItem } from 'tg.component/navigation/Navigation'; +import { ComponentType } from 'react'; + +type PlanMigrationRecord = components['schemas']['PlanMigrationRecordModel']; + +type EditFormComponentProps = { + migration: M; + onSubmit: () => void; + onDelete?: () => void; +}; + +type Props = { + migrations: UseQueryResult; + subscriptions: UseQueryResult>; + navigation: NavigationItem[]; + listLink: string; + form: ComponentType>; + onPage: (page: number) => void; +}; + +export const AdministrationPlanMigrationEditBase = ({ + migrations, + subscriptions, + form: FormComponent, + listLink, + navigation, + onPage, +}: Props) => { + const { t } = useTranslate(); + const messaging = useMessage(); + const history = useHistory(); + + const onDelete = () => { + messaging.success( + + ); + history.push(listLink); + }; + + const onSubmit = () => { + messaging.success( + + ); + history.push(listLink); + }; + + if (migrations.isLoading) { + return ; + } + + const migration = migrations.data!; + + return ( + + + + + {t('administration_plan_migration_configure_existing')} + + + + + + {t('administration_plan_migration_migrated_subscriptions')} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationRecordList.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationRecordList.tsx new file mode 100644 index 0000000000..3a4dc37f9e --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationRecordList.tsx @@ -0,0 +1,74 @@ +import { Link, TableCell, TableRow } from '@mui/material'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { PlanMigrationStatus } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus'; +import { EmptyState } from 'tg.component/common/EmptyState'; +import { T, useTranslate } from '@tolgee/react'; +import { PaginatedHateoasTable } from 'tg.component/common/table/PaginatedHateoasTable'; +import React from 'react'; +import { UseQueryResult } from 'react-query'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { HateoasListData } from 'tg.service/response.types'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { Link as RouterLink } from 'react-router-dom'; + +type PlanMigrationRecord = components['schemas']['PlanMigrationRecordModel']; + +type Props = { + subscriptions: UseQueryResult>; + setPage: (page: number) => void; +}; + +export const PlanMigrationRecordList = ({ subscriptions, setPage }: Props) => { + const { t } = useTranslate(); + const formatDate = useDateFormatter(); + + return ( + + {t('global_organization')} + {t('administration_plan_migration_from')} + {t('administration_plan_migration_to')} + {t('administration_plan_migrated_at')} + + {t('administration_plan_migrated_subscription_status')} + + + } + renderItem={(item) => ( + + + + {item.organizationName} + + + {item.originPlan} + {item.plan} + + {formatDate(item.scheduledAt, { + timeZone: 'UTC', + dateStyle: 'short', + timeStyle: 'short', + })} + + + + + + )} + emptyPlaceholder={ + + + + } + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx new file mode 100644 index 0000000000..771830852e --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx @@ -0,0 +1,49 @@ +import { components } from 'tg.service/billingApiSchema.generated'; +import { Chip, Tooltip } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { useDateFormatter } from 'tg.hooks/useLocale'; + +type Status = components['schemas']['PlanMigrationRecordModel']['status']; + +type Props = { + status: Status; + date?: number; +}; + +const colors = { + COMPLETED: 'success', +}; + +export const PlanMigrationStatus = ({ status, date }: Props) => { + const { t } = useTranslate(); + const formatDate = useDateFormatter(); + + const getStatusLabel = (s: Status): string => { + switch (s) { + case 'COMPLETED': + return t('administration_plan_migration_status_completed'); + case 'SCHEDULED': + return t('administration_plan_migration_status_scheduled'); + default: + return String(s); + } + }; + + const chip = ( + + ); + + return date ? ( + + {chip} + + ) : ( + chip + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx new file mode 100644 index 0000000000..fe82ba0b19 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx @@ -0,0 +1,48 @@ +import { useTranslate } from '@tolgee/react'; +import { LINKS } from 'tg.constants/links'; +import { CreatePlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { AdministrationPlanMigrationCreateBase } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationCreateBase'; + +export const AdministrationSelfHostedEePlanMigrationCreate = () => { + const { t } = useTranslate(); + + const createMutation = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration', + method: 'post', + }); + + const onSubmit = ( + values: CreatePlanMigrationFormData, + callbacks: { onSuccess: () => void } + ) => { + createMutation.mutate( + { + content: { 'application/json': values }, + }, + { + onSuccess: callbacks.onSuccess, + } + ); + }; + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx new file mode 100644 index 0000000000..1e7b0dc4ef --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { AdministrationPlanMigrationEditBase } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationEditBase'; +import { SelfHostedEePlanEditPlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/SelfHostedEePlanEditPlanMigrationForm'; +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import { useHistory, useRouteMatch } from 'react-router-dom'; + +export const AdministrationSelfHostedEePlanMigrationEdit = () => { + const { t } = useTranslate(); + const match = useRouteMatch(); + const [subscriptionsPage, setSubscriptionsPage] = useState(0); + const migrationId = Number(match.params[PARAMS.PLAN_MIGRATION_ID]); + const history = useHistory(); + + if (isNaN(migrationId)) { + history.replace(LINKS.ADMINISTRATION_BILLING_EE_PLANS.build()); + } + + const migrations = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'get', + path: { migrationId }, + }); + + const subscriptions = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/subscriptions', + method: 'get', + path: { migrationId }, + query: { + page: subscriptionsPage, + size: 10, + }, + options: { + keepPreviousData: true, + }, + }); + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx index c6cc238a3a..d6063ad192 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx @@ -9,7 +9,7 @@ import { Paper, styled, } from '@mui/material'; -import { X } from '@untitled-ui/icons-react'; +import { Settings01, X } from '@untitled-ui/icons-react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS, PARAMS } from 'tg.constants/links'; @@ -26,6 +26,7 @@ import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanS import { PlanListPriceInfo } from 'tg.ee.module/billing/component/Plan/PlanListPriceInfo'; import { PlanArchivedChip } from 'tg.ee.module/billing/component/Plan/PlanArchivedChip'; import clsx from 'clsx'; +import { CloudPlanMigratingChip } from 'tg.ee.module/billing/component/Plan/migration/CloudPlanMigratingChip'; type CloudPlanModel = components['schemas']['CloudPlanModel']; @@ -112,6 +113,20 @@ export const AdministrationCloudPlansView = () => { hideChildrenOnLoading={false} addLinkTo={LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_CREATE.build()} onAdd={() => {}} + customButtons={[ + , + ]} > {plansLoadable.data?._embedded?.plans?.map((plan, i) => ( @@ -130,6 +145,10 @@ export const AdministrationCloudPlansView = () => { + diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx index 4469831ab2..a2b5301971 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx @@ -10,7 +10,7 @@ import { Paper, styled, } from '@mui/material'; -import { X } from '@untitled-ui/icons-react'; +import { Settings01, X } from '@untitled-ui/icons-react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS, PARAMS } from 'tg.constants/links'; @@ -26,6 +26,7 @@ import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanS import { PlanListPriceInfo } from 'tg.ee.module/billing/component/Plan/PlanListPriceInfo'; import { PlanArchivedChip } from 'tg.ee.module/billing/component/Plan/PlanArchivedChip'; import clsx from 'clsx'; +import { SelfHostedEePlanMigratingChip } from 'tg.ee.module/billing/component/Plan/migration/SelfHostedEePlanMigratingChip'; type SelfHostedEePlanAdministrationModel = components['schemas']['SelfHostedEePlanAdministrationModel']; @@ -113,6 +114,20 @@ export const AdministrationEePlansView = () => { hideChildrenOnLoading={false} addLinkTo={LINKS.ADMINISTRATION_BILLING_EE_PLAN_CREATE.build()} onAdd={() => {}} + customButtons={[ + , + ]} > {plansLoadable.data?._embedded?.plans?.map((plan, i) => ( @@ -137,6 +152,10 @@ export const AdministrationEePlansView = () => { label={t('administration_ee_plan_public_badge')} /> )} + diff --git a/webapp/src/ee/billing/component/Plan/migration/CloudPlanMigratingChip.tsx b/webapp/src/ee/billing/component/Plan/migration/CloudPlanMigratingChip.tsx new file mode 100644 index 0000000000..74a2358588 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/migration/CloudPlanMigratingChip.tsx @@ -0,0 +1,35 @@ +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import React, { useState } from 'react'; +import { LINKS } from 'tg.constants/links'; +import { PlanMigrationChip } from 'tg.ee.module/billing/component/Plan/migration/PlanMigrationChip'; + +export const CloudPlanMigratingChip = ({ + migrationId, + isEnabled, +}: { + migrationId?: number; + isEnabled?: boolean; +}) => { + const [opened, setOpened] = useState(false); + const loadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'get', + path: { migrationId: migrationId! }, + options: { + enabled: !!migrationId && opened, + }, + }); + + if (!migrationId) { + return null; + } + + return ( + setOpened(true)} + /> + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/migration/PlanMigrationChip.tsx b/webapp/src/ee/billing/component/Plan/migration/PlanMigrationChip.tsx new file mode 100644 index 0000000000..90cd10b4a5 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/migration/PlanMigrationChip.tsx @@ -0,0 +1,67 @@ +import { FullWidthTooltip } from 'tg.component/common/FullWidthTooltip'; +import { Box, Chip, Typography } from '@mui/material'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { PlanMigrationDetail } from 'tg.ee.module/billing/component/Plan/migration/PlanMigrationDetail'; +import { T, useTranslate } from '@tolgee/react'; +import React from 'react'; +import { UseQueryResult } from 'react-query'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { Link } from 'tg.constants/links'; + +type CloudPlanModel = components['schemas']['CloudPlanMigrationModel']; +type SelfHostedEePlanAdministrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +type Props = { + loadable: UseQueryResult< + CloudPlanModel | SelfHostedEePlanAdministrationModel + >; + onOpen?: () => void; + editLink: Link; + isEnabled?: boolean; +}; + +export const PlanMigrationChip = ({ + loadable, + onOpen, + editLink, + isEnabled, +}: Props) => { + const { t } = useTranslate(); + const migration = loadable.data; + return ( + + + + ) : migration ? ( + + ) : ( + + + {t('administration_plan_migration_not_found')} + + + ) + } + > + + ) : ( + + ) + } + /> + + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/migration/PlanMigrationDetail.tsx b/webapp/src/ee/billing/component/Plan/migration/PlanMigrationDetail.tsx new file mode 100644 index 0000000000..8dcae798f9 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/migration/PlanMigrationDetail.tsx @@ -0,0 +1,113 @@ +import { Box, Button, Chip, styled, Typography } from '@mui/material'; +import clsx from 'clsx'; +import { ArrowRight, Settings01 } from '@untitled-ui/icons-react'; +import { T, useTranslate } from '@tolgee/react'; +import React from 'react'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { Link } from 'tg.constants/links'; +import { Link as RouterLink } from 'react-router-dom'; +import { PlanMigrationPlanPriceDetail } from 'tg.ee.module/billing/component/Plan/migration/PlanMigrationPlanPriceDetail'; + +type CloudPlanModel = components['schemas']['CloudPlanMigrationModel']; +type SelfHostedEePlanAdministrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +const TooltipText = styled('div')` + white-space: nowrap; +`; + +const MigrationDetailBox = styled(Box)` + &.inactive { + opacity: 0.5; + } +`; + +type Props = { + migration: CloudPlanModel | SelfHostedEePlanAdministrationModel; + editLink: Link; +}; + +export const PlanMigrationDetail = ({ migration, editLink }: Props) => { + const { t } = useTranslate(); + if (!migration) { + return ( + + + {t('administration_plan_migration_not_found')} + + + ); + } + return ( + + + + {t('administration_plan_migration_details')} + + {migration.enabled ? ( + + ) : ( + + )} + + + + + + + + {t('administration_plan_migration_timing')} + + + + }} + /> + + + }} + /> + + + + + + + + + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/migration/PlanMigrationPlanPriceDetail.tsx b/webapp/src/ee/billing/component/Plan/migration/PlanMigrationPlanPriceDetail.tsx new file mode 100644 index 0000000000..00eee2e77b --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/migration/PlanMigrationPlanPriceDetail.tsx @@ -0,0 +1,59 @@ +import { Box, Chip, styled, Typography } from '@mui/material'; +import { PricePrimary } from 'tg.ee.module/billing/component/Price/PricePrimary'; +import React from 'react'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type CloudPlan = components['schemas']['CloudPlanModel']; +type SelfHostedEePlanAdministrationModel = + components['schemas']['SelfHostedEePlanModel']; + +const TooltipTitle = styled('div')` + font-weight: bold; + font-size: 14px; + line-height: 17px; +`; + +const TooltipText = styled('div')` + white-space: nowrap; + overflow-wrap: anywhere; +`; + +type Props = { + plan: CloudPlan | SelfHostedEePlanAdministrationModel; + label: string; +}; + +export const PlanMigrationPlanPriceDetail = ({ plan, label }: Props) => { + return ( + + {label} + {plan.name} + {plan.prices && ( + + + + )} + + } + /> + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/migration/SelfHostedEePlanMigratingChip.tsx b/webapp/src/ee/billing/component/Plan/migration/SelfHostedEePlanMigratingChip.tsx new file mode 100644 index 0000000000..a692938823 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/migration/SelfHostedEePlanMigratingChip.tsx @@ -0,0 +1,35 @@ +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import React, { useState } from 'react'; +import { LINKS } from 'tg.constants/links'; +import { PlanMigrationChip } from 'tg.ee.module/billing/component/Plan/migration/PlanMigrationChip'; + +export const SelfHostedEePlanMigratingChip = ({ + migrationId, + isEnabled, +}: { + migrationId?: number; + isEnabled?: boolean; +}) => { + const [opened, setOpened] = useState(false); + const loadable = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'get', + path: { migrationId: migrationId! }, + options: { + enabled: !!migrationId && opened, + }, + }); + + if (!migrationId) { + return null; + } + + return ( + setOpened(true)} + /> + ); +}; diff --git a/webapp/src/eeSetup/eeModule.ee.tsx b/webapp/src/eeSetup/eeModule.ee.tsx index b42153833a..0489eed74b 100644 --- a/webapp/src/eeSetup/eeModule.ee.tsx +++ b/webapp/src/eeSetup/eeModule.ee.tsx @@ -68,6 +68,10 @@ import { ProjectSettingsTab } from '../views/projects/project/ProjectSettingsVie import { OperationAssignTranslationLabel } from '../ee/batchOperations/OperationAssignTranslationLabel'; import { OperationUnassignTranslationLabel } from '../ee/batchOperations/OperationUnassignTranslationLabel'; import { ProjectSettingsLabels } from '../ee/translationLabels/ProjectSettingsLabels'; +import { AdministrationCloudPlanMigrationCreate } from '../ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate'; +import { AdministrationCloudPlanMigrationEdit } from '../ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit'; +import { AdministrationSelfHostedEePlanMigrationCreate } from '../ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate'; +import { AdministrationSelfHostedEePlanMigrationEdit } from '../ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit'; export { TaskReference } from '../ee/task/components/TaskReference'; export { GlobalLimitPopover } from '../ee/billing/limitPopover/GlobalLimitPopover'; @@ -139,6 +143,18 @@ export const routes = { > + + + + + + + + + + + +