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 003ceb0e51..4513a4ef4f 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 @@ -86,6 +88,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 34b8c3e148..57ac840f0f 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.UUID @@ -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/component/email/customTemplate/EmailTemplateRenderer.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/EmailTemplateRenderer.kt new file mode 100644 index 0000000000..095834288b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/EmailTemplateRenderer.kt @@ -0,0 +1,31 @@ +package io.tolgee.component.email.customTemplate + +import io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholdersExtractor +import org.springframework.stereotype.Component +import java.text.MessageFormat +import java.util.Locale +import kotlin.reflect.KClass + +@Component +class EmailTemplateRenderer( + private val placeholderExtractor: EmailPlaceholdersExtractor, +) { + fun render( + template: String, + variables: EmailTemplateVariables, + ): String { + @Suppress("UNCHECKED_CAST") + val entries = + placeholderExtractor.getEntries( + variables::class as KClass, + ) + + val parameters = + entries + .map { entry -> + entry.accessor(variables) ?: "" + }.toTypedArray() + + return MessageFormat(template, Locale.ENGLISH).format(parameters) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/EmailTemplateVariables.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/EmailTemplateVariables.kt new file mode 100644 index 0000000000..aaeae086f0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/EmailTemplateVariables.kt @@ -0,0 +1,6 @@ +package io.tolgee.component.email.customTemplate + +/** + * Marker interface for classes that describe email template variables via [io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholder] annotations. + */ +interface EmailTemplateVariables diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholder.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholder.kt new file mode 100644 index 0000000000..4cee723be5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholder.kt @@ -0,0 +1,10 @@ +package io.tolgee.component.email.customTemplate.placeholder + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class EmailPlaceholder( + val position: Int, + val placeholder: String, + val description: String, + val exampleValue: String, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholderDefinition.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholderDefinition.kt new file mode 100644 index 0000000000..926746c189 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholderDefinition.kt @@ -0,0 +1,15 @@ +package io.tolgee.component.email.customTemplate.placeholder + +import io.tolgee.component.email.customTemplate.EmailTemplateVariables + +data class EmailPlaceholderDefinition( + val position: Int, + val placeholder: String, + val description: String, + val exampleValue: String, +) + +data class EmailPlaceholderEntry( + val definition: EmailPlaceholderDefinition, + val accessor: (T) -> String?, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholdersExtractor.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholdersExtractor.kt new file mode 100644 index 0000000000..3c2cc155b5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/customTemplate/placeholder/EmailPlaceholdersExtractor.kt @@ -0,0 +1,51 @@ +package io.tolgee.component.email.customTemplate.placeholder + +import io.tolgee.component.email.customTemplate.EmailTemplateVariables +import org.springframework.stereotype.Component +import java.util.concurrent.ConcurrentHashMap +import kotlin.collections.set +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.memberProperties + +@Component +class EmailPlaceholdersExtractor { + private val cache = + ConcurrentHashMap, List>>() + + fun getEntries(kClass: KClass): List> { + val existing = cache[kClass] + if (existing != null) { + @Suppress("UNCHECKED_CAST") + return existing as List> + } + + val extracted = extract(kClass) + cache[kClass] = extracted + return extracted + } + + fun getDefinitions(kClass: KClass): List { + return getEntries(kClass).map { it.definition } + } + + private fun extract(kClass: KClass): List> { + return kClass.memberProperties + .mapNotNull { property -> + val annotation = property.findAnnotation() ?: return@mapNotNull null + + EmailPlaceholderEntry( + definition = + EmailPlaceholderDefinition( + position = annotation.position, + placeholder = annotation.placeholder, + description = annotation.description, + exampleValue = annotation.exampleValue, + ), + accessor = { instance: T -> + property.get(instance)?.toString() + }, + ) + }.sortedBy { it.definition.position } + } +} 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 7df47d1ed4..f84df6b6eb 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -312,6 +312,8 @@ enum class Message { ALREADY_IMPERSONATING_USER, OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE, FILE_PROCESSING_FAILED, + PLAN_MIGRATION_NOT_FOUND, + PLAN_HAS_MIGRATIONS, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailPlaceholderModel.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailPlaceholderModel.kt new file mode 100644 index 0000000000..2ce1e5bbd7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailPlaceholderModel.kt @@ -0,0 +1,7 @@ +package io.tolgee.dtos.misc + +data class EmailPlaceholderModel( + val placeholder: String, + val description: String, + val exampleValue: String, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailTemplateModel.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailTemplateModel.kt new file mode 100644 index 0000000000..f2b55eee5c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailTemplateModel.kt @@ -0,0 +1,6 @@ +package io.tolgee.dtos.misc + +data class EmailTemplateModel( + val body: String, + val placeholders: List, +) diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index 713a3522a8..3ef29ca77a 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 on {3}. 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 7b7dd6f2e3..4a7227524d 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt @@ -10,6 +10,7 @@ import org.mockito.Mockito import org.mockito.kotlin.KArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired @@ -72,6 +73,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 f3d47158ae..c986d8080b 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" | @@ -197,6 +200,7 @@ declare namespace DataCy { "billing-plan-included-keys" | "billing-plan-included-seats" | "billing-plan-included-strings" | + "billing-plan-migration-alert" | "billing-plan-monthly-price" | "billing-plan-price-extra-seat" | "billing-plan-price-extra-thousand-keys" | @@ -263,6 +267,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" | @@ -568,6 +573,9 @@ declare namespace DataCy { "permissions-menu-save" | "plan-limit-dialog-close" | "plan-limit-exceeded-popover" | + "plan-migration-tab-migrated" | + "plan-migration-tab-upcoming" | + "plan-migration-tooltip-detail" | "plan_seat_limit_exceeded_while_accepting_invitation_message" | "project-ai-prompt-dialog-description-input" | "project-ai-prompt-dialog-save" | @@ -679,6 +687,7 @@ declare namespace DataCy { "seat_spending_limit_exceeded_while_accepting_invitation_message" | "self-hosted-ee-active-plan" | "self-hosted-ee-subscription-metrics" | + "send-preview-email" | "sensitive-dialog-otp-input" | "sensitive-dialog-password-input" | "sensitive-protection-dialog" | @@ -687,6 +696,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" | @@ -711,6 +721,8 @@ declare namespace DataCy { "storage-subtitle" | "submenu-item" | "subscribe-cancels-trial-plan-tooltip" | + "subscription-period-" | + "subscription-status-" | "subscriptions-cloud-popover-active-plan-name" | "subscriptions-trial-alert" | "subscriptions-trial-alert-reaching-the-limit" | @@ -718,6 +730,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/form/HtmlTemplateEditor.tsx b/webapp/src/component/common/form/HtmlTemplateEditor.tsx new file mode 100644 index 0000000000..fc42bc37fb --- /dev/null +++ b/webapp/src/component/common/form/HtmlTemplateEditor.tsx @@ -0,0 +1,291 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + Card, + Chip, + Stack, + Tab, + Tabs, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { + Bold01, + Code01, + Eye, + Italic01, + Underline01, +} from '@untitled-ui/icons-react'; +import { components } from 'tg.service/billingApiSchema.generated'; + +export type TemplatePlaceholder = + components['schemas']['EmailPlaceholderModel']; + +type Props = { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + readOnly?: boolean; + placeholders?: TemplatePlaceholder[]; +}; + +type Mode = 'html' | 'preview'; + +export const HtmlTemplateEditor: React.FC = ({ + value, + onChange, + disabled, + readOnly, + placeholders = [], +}) => { + const { t } = useTranslate(); + const [mode, setMode] = useState('html'); + const textareaRef = useRef(null); + const previewRef = useRef(null); + + useEffect(() => { + if ( + mode === 'preview' && + previewRef.current && + previewRef.current.innerHTML !== value + ) { + previewRef.current.innerHTML = value ?? ''; + } + }, [mode, value]); + + const updateValue = (newValue: string) => { + if (readOnly || disabled) return; + onChange(newValue); + }; + + const getSelection = () => { + const el = textareaRef.current; + if (!el) return null; + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + return { start, end, value: el.value }; + }; + + const wrapOrUnwrapSelection = (tag: 'b' | 'i' | 'u') => { + if (mode === 'preview') { + document.execCommand( + tag === 'u' ? 'underline' : tag === 'i' ? 'italic' : 'bold', + false + ); + if (previewRef.current) { + updateValue(previewRef.current.innerHTML); + } + return; + } + + if (readOnly || disabled) return; + const selection = getSelection(); + if (!selection) return; + const { start, end, value: currentValue } = selection; + const selected = currentValue.slice(start, end); + const open = `<${tag}>`; + const close = ``; + + const beforeSel = currentValue.slice(0, start); + const afterSel = currentValue.slice(end); + const selectedLower = selected.toLowerCase(); + const openLower = open.toLowerCase(); + const closeLower = close.toLowerCase(); + + let newValue: string; + let newPos = start; + + if ( + selectedLower.startsWith(openLower) && + selectedLower.endsWith(closeLower) + ) { + const inner = selected.slice(open.length, selected.length - close.length); + newValue = beforeSel + inner + afterSel; + newPos = start; + } else if ( + beforeSel.toLowerCase().endsWith(openLower) && + afterSel.toLowerCase().startsWith(closeLower) + ) { + const trimmedBefore = beforeSel.slice(0, beforeSel.length - open.length); + const trimmedAfter = afterSel.slice(close.length); + newValue = trimmedBefore + selected + trimmedAfter; + newPos = start - open.length; + } else { + newValue = beforeSel + open + selected + close + afterSel; + newPos = start + open.length + selected.length + close.length; + } + + updateValue(newValue); + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.selectionStart = newPos; + textareaRef.current.selectionEnd = newPos; + } + }); + }; + + const handleTabChange = (_: React.SyntheticEvent, newValue: Mode) => { + setMode(newValue); + }; + + const insertPlaceholder = (placeholder: string) => { + if (mode === 'preview') { + document.execCommand('insertHTML', false, placeholder); + if (previewRef.current) { + updateValue(previewRef.current.innerHTML); + } + return; + } + const selection = getSelection(); + if (!selection) return; + const { start, end, value: currentValue } = selection; + const newValue = `${currentValue.slice( + 0, + start + )}${placeholder}${currentValue.slice(end)}`; + updateValue(newValue); + requestAnimationFrame(() => { + if (textareaRef.current) { + const pos = start + placeholder.length; + textareaRef.current.selectionStart = pos; + textareaRef.current.selectionEnd = pos; + textareaRef.current.focus(); + } + }); + }; + + return ( + + + + + wrapOrUnwrapSelection('b')} + disabled={disabled || readOnly} + > + + + wrapOrUnwrapSelection('i')} + disabled={disabled || readOnly} + > + + + wrapOrUnwrapSelection('u')} + disabled={disabled || readOnly} + > + + + + + + + {t('html_editor_source_label')} + + } + /> + + + {t('html_editor_preview_label')} + + } + /> + + + + {mode === 'html' ? ( + updateValue(e.target.value)} + disabled={disabled} + InputProps={{ + readOnly, + }} + /> + ) : ( + { + if (previewRef.current) { + updateValue(previewRef.current.innerHTML); + } + }} + /> + )} + + {!!placeholders.length && ( + + + {t('html_editor_placeholders_label')} + + + {placeholders.map((ph) => { + const chip = ( + insertPlaceholder(ph.placeholder)} + clickable={!disabled && !readOnly} + disabled={disabled || readOnly} + /> + ); + if (!ph.exampleValue) { + return chip; + } + return ( + + } + > + {chip} + + ); + })} + + + )} + + + ); +}; 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/common/tooltip/FormatedDateTooltip.tsx b/webapp/src/component/common/tooltip/FormatedDateTooltip.tsx new file mode 100644 index 0000000000..7565228759 --- /dev/null +++ b/webapp/src/component/common/tooltip/FormatedDateTooltip.tsx @@ -0,0 +1,18 @@ +import { Tooltip } from '@mui/material'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { useTimeDistance } from 'tg.hooks/useTimeDistance'; + +export const FormatedDateTooltip = ({ date }: { date: number }) => { + const formatDate = useDateFormatter(); + const distance = useTimeDistance(); + return ( + + {distance(date)} + + ); +}; 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..29a4211588 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,13 @@ 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), + customEmailBody: Yup.string().nullable(), + }); } 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..a15adf9a22 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx @@ -0,0 +1,29 @@ +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, + customEmailBody: undefined, +}; + +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..a13146862d --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx @@ -0,0 +1,33 @@ +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, + customEmailBody: migration.customEmailBody ?? undefined, + }; + return ( + + defaultValues={initialValues} + {...props} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationEmailSection.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationEmailSection.tsx new file mode 100644 index 0000000000..7075aa49fa --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationEmailSection.tsx @@ -0,0 +1,50 @@ +import { FC } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { useFormikContext } from 'formik'; +import { + CreatePlanMigrationFormData, + EmailTemplateData, + PlanMigrationFormData, +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Typography, +} from '@mui/material'; +import { HtmlTemplateEditor } from 'tg.component/common/form/HtmlTemplateEditor'; +import { ArrowDropDown } from 'tg.component/CustomIcons'; + +type EmailSectionProps = { + template?: EmailTemplateData; +}; + +export const PlanMigrationEmailSection: FC = ({ + template, +}) => { + const { t } = useTranslate(); + const { values, setFieldValue } = useFormikContext< + CreatePlanMigrationFormData | PlanMigrationFormData + >(); + + return ( + + }> + + {t('administration_plan_migration_email_section_title')} + + + + + setFieldValue('customEmailBody', val)} + disabled={!template} + placeholders={template?.placeholders ?? []} + /> + + + + ); +}; 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..5567014803 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -0,0 +1,305 @@ +import { Form, Formik } from 'formik'; +import { Box, Button, InputAdornment, Stack, 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'; +import { + useBillingApiMutation, + useBillingApiQuery, +} from 'tg.service/http/useQueryApi'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { PlanMigrationEmailSection } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationEmailSection'; + +type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; +type SelfHostedEePlanMigrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +export type EmailTemplateData = components['schemas']['EmailTemplateModel']; + +const normalizeCustomBody = ( + body: string | null | undefined, + template?: EmailTemplateData +): string | null | undefined => { + if (!body || !body.trim()) { + return null; + } + if (template && body === template.body) { + return null; + } + return body; +}; + +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; + sourcePlanId?: number; + }; + +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 messaging = useMessage(); + 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 templateQuery = useBillingApiQuery({ + url: '/v2/administration/billing/plan-migration/email-template', + method: 'get', + }); + + const cloudPreviewMutation = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration/email-preview', + method: 'post', + }); + + const selfHostedPreviewMutation = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/email-preview', + method: 'post', + }); + + const previewMutation = + planType === 'cloud' ? cloudPreviewMutation : selfHostedPreviewMutation; + + const templateData: EmailTemplateData | undefined = templateQuery.data; + + const initValues = { + ...defaultValues, + ...(isUpdate && + migration && { + sourcePlanId: migration.sourcePlan.id, + customEmailBody: migration.customEmailBody ?? null, + }), + } as T; + + const sendPreview = (values: T) => { + if (!values.sourcePlanId || !values.targetPlanId) { + return; + } + const body = normalizeCustomBody(values.customEmailBody, templateData); + previewMutation.mutate( + { + content: { + 'application/json': { + sourcePlanId: values.sourcePlanId, + targetPlanId: values.targetPlanId, + customEmailBody: body ?? undefined, + }, + }, + }, + { + onSuccess: () => + messaging.success( + + ), + } + ); + }; + + if (!templateData) { + return ; + } + + const handleSubmit = (vals: T) => { + const normalizedBody = normalizeCustomBody( + vals.customEmailBody, + templateData + ); + onSubmit({ ...vals, customEmailBody: normalizedBody }); + }; + + return ( + + initialValues={initValues} + enableReinitialize + onSubmit={handleSubmit} + validationSchema={Validation.PLAN_MIGRATION_FORM} + > + {({ values }) => { + const previewDisabled = + !values.sourcePlanId || !values.targetPlanId || !templateData; + + return ( +
+ + + + + { + 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 && ( + + )} + + + sendPreview(values)} + > + {t('administration_plan_migration_send_preview')} + + + {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..3850da8846 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx @@ -0,0 +1,36 @@ +import { useFormikContext } from 'formik'; +import { CloudPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector'; +import { + CreatePlanMigrationFormData, + 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< + PlanMigrationFormData | CreatePlanMigrationFormData + >(); + + 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..d5f806a361 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -0,0 +1,99 @@ +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 { + useBillingApiMutation, + 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'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type CloudPlanMigration = components['schemas']['CloudPlanMigrationModel']; + +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 [upcomingPage, setUpcomingPage] = 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, + }, + }); + + const upcomingSubscriptions = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}/upcoming-subscriptions', + method: 'get', + path: { migrationId }, + query: { + page: upcomingPage, + size: 10, + }, + options: { + keepPreviousData: true, + }, + }); + + const toggleUpcomingSkip = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}/upcoming-subscriptions/{subscriptionId}/skip', + method: 'put', + invalidatePrefix: + '/v2/administration/billing/cloud-plans/migration/{migrationId}/upcoming-subscriptions', + }); + + const onToggleUpcomingSkip = (subscriptionId: number, skipped: boolean) => { + toggleUpcomingSkip.mutate({ + path: { migrationId, subscriptionId }, + content: { 'application/json': { skipped } }, + }); + }; + + return ( + + migrations={migrations} + subscriptions={subscriptions} + upcomingSubscriptions={upcomingSubscriptions} + navigation={[ + [ + t('administration_cloud_plans'), + LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build(), + ], + [ + t('administration_plan_migration_configure_existing'), + LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT.build({ + [PARAMS.PLAN_MIGRATION_ID]: migrationId, + }), + ], + ]} + listLink={LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()} + form={CloudPlanEditPlanMigrationForm} + onPage={setSubscriptionsPage} + onUpcomingPage={setUpcomingPage} + onToggleUpcomingSkip={onToggleUpcomingSkip} + upcomingToggleLoading={toggleUpcomingSkip.isLoading} + /> + ); +}; 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..db31028259 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/AdministrationPlanMigrationEditBase.tsx @@ -0,0 +1,136 @@ +import { Box, Tab, Tabs, 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 { NavigationItem } from 'tg.component/navigation/Navigation'; +import React, { ComponentType, useState } from 'react'; +import { PlanMigrationUpcomingList } from './PlanMigrationUpcomingList'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type PlanMigrationRecord = components['schemas']['PlanMigrationRecordModel']; +type PlanMigration = { + monthlyOffsetDays: number; + yearlyOffsetDays: number; +}; + +type PlanMigrationUpcoming = + components['schemas']['PlanMigrationUpcomingSubscriptionModel']; + +type EditFormComponentProps = { + migration: M; + onSubmit: () => void; + onDelete?: () => void; +}; + +type Props = { + migrations: UseQueryResult; + subscriptions: UseQueryResult>; + upcomingSubscriptions: UseQueryResult>; + navigation: NavigationItem[]; + listLink: string; + form: ComponentType>; + onPage: (page: number) => void; + onUpcomingPage: (page: number) => void; + onToggleUpcomingSkip: (subscriptionId: number, skipped: boolean) => void; + upcomingToggleLoading?: boolean; +}; + +export const AdministrationPlanMigrationEditBase = ({ + migrations, + subscriptions, + upcomingSubscriptions, + form: FormComponent, + listLink, + navigation, + onPage, + onUpcomingPage, + onToggleUpcomingSkip, + upcomingToggleLoading, +}: Props) => { + const { t } = useTranslate(); + const messaging = useMessage(); + const history = useHistory(); + const [tab, setTab] = useState(0); + + 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')} + + + + + setTab(value)} + aria-label="plan migration subscription tabs" + > + + + + + {tab === 0 && ( + + )} + {tab === 1 && ( + <> + + + )} + + + ); +}; 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..162c6f72d3 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationRecordList.tsx @@ -0,0 +1,73 @@ +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 { Link as RouterLink } from 'react-router-dom'; +import { FormatedDateTooltip } from 'tg.component/common/tooltip/FormatedDateTooltip'; + +type PlanMigrationRecord = components['schemas']['PlanMigrationRecordModel']; + +type Props = { + subscriptions: UseQueryResult>; + setPage: (page: number) => void; +}; + +export const PlanMigrationRecordList = ({ subscriptions, setPage }: Props) => { + const { t } = useTranslate(); + + return ( + + {t('global_organization')} + {t('administration_plan_migration_from')} + {t('administration_plan_migration_to')} + {t('administration_plan_scheduled_at')} + {t('administration_plan_migrated_at')} + + {t('administration_plan_migrated_subscription_status')} + + + } + renderItem={(item) => ( + + + + {item.organizationName} + + + {item.originPlan} + {item.plan} + + + + + + + + + + + )} + 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..475703a2c1 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx @@ -0,0 +1,57 @@ +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'] + | 'TO_BE_SCHEDULED'; + +type Props = { + status: Status; + date?: number; +}; + +const colors = { + COMPLETED: 'success', + SKIPPED: 'default', + TO_BE_SCHEDULED: 'info', +}; + +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'); + case 'SKIPPED': + return t('administration_plan_migration_status_skipped'); + case 'TO_BE_SCHEDULED': + return t('administration_plan_migration_status_to_be_scheduled'); + default: + return String(s); + } + }; + + const chip = ( + + ); + + return date ? ( + + {chip} + + ) : ( + chip + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingItem.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingItem.tsx new file mode 100644 index 0000000000..7fcb1146d8 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingItem.tsx @@ -0,0 +1,64 @@ +import { Checkbox, Link, styled, TableCell, TableRow } from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; + +import { PlanMigrationStatus } from './PlanMigrationStatus'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { FormatedDateTooltip } from 'tg.component/common/tooltip/FormatedDateTooltip'; + +type UpcomingItem = + components['schemas']['PlanMigrationUpcomingSubscriptionModel']; + +type Props = { + subscription: UpcomingItem; + onToggleSkip: (subscriptionId: number, skipped: boolean) => void; + toggleLoading?: boolean; +}; + +const StyledTableRow = styled(TableRow)<{ skipped: boolean }>` + .MuiTableCell-body { + color: ${({ skipped, theme }) => + skipped ? theme.palette.text.disabled : 'inherit'}; + } +`; + +export const PlanMigrationUpcomingItem = ({ + subscription, + onToggleSkip, + toggleLoading, +}: Props) => { + return ( + + + + {subscription.organizationName} + + + {subscription.originPlan} + {subscription.targetPlan} + + + + + + onToggleSkip(subscription.subscriptionId, e.target.checked) + } + disabled={toggleLoading} + /> + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingList.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingList.tsx new file mode 100644 index 0000000000..2b91be4f25 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingList.tsx @@ -0,0 +1,76 @@ +import { Alert, Box, TableCell, TableRow } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { UseQueryResult } from 'react-query'; + +import { EmptyState } from 'tg.component/common/EmptyState'; +import { PaginatedHateoasTable } from 'tg.component/common/table/PaginatedHateoasTable'; +import { HateoasListData } from 'tg.service/response.types'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { PlanMigrationUpcomingItem } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/PlanMigrationUpcomingItem'; + +type UpcomingItem = + components['schemas']['PlanMigrationUpcomingSubscriptionModel']; + +type Props = { + subscriptions: UseQueryResult>; + setPage: (page: number) => void; + onToggleSkip: (subscriptionId: number, skipped: boolean) => void; + toggleLoading?: boolean; + monthlyOffsetDays: number; + yearlyOffsetDays: number; +}; + +export const PlanMigrationUpcomingList = ({ + subscriptions, + setPage, + onToggleSkip, + toggleLoading, +}: Props) => { + const { t } = useTranslate(); + + return ( + <> + + + + + + + {t('global_organization')} + {t('administration_plan_migration_from')} + {t('administration_plan_migration_to')} + + {t('administration_plan_migration_scheduled_at')} + + + {t('administration_plan_migration_skip_label')} + + + {t('administration_plan_migrated_subscription_status')} + + + } + renderItem={(item) => ( + + )} + emptyPlaceholder={ + + + + } + /> + + ); +}; 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..c941ce5a7f --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -0,0 +1,95 @@ +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 { + useBillingApiMutation, + 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 [upcomingPage, setUpcomingPage] = 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, + }, + }); + + const upcomingSubscriptions = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/upcoming-subscriptions', + method: 'get', + path: { migrationId }, + query: { + page: upcomingPage, + size: 10, + }, + options: { + keepPreviousData: true, + }, + }); + + const toggleUpcomingSkip = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/upcoming-subscriptions/{subscriptionId}/skip', + method: 'put', + invalidatePrefix: + '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/upcoming-subscriptions', + }); + + const onToggleUpcomingSkip = (subscriptionId: number, skipped: boolean) => { + toggleUpcomingSkip.mutate({ + path: { migrationId, subscriptionId }, + content: { 'application/json': { skipped } }, + }); + }; + + 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..19acf252ee 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'; @@ -22,10 +22,11 @@ import { useMessage } from 'tg.hooks/useSuccessMessage'; import { confirmation } from 'tg.hooks/confirmation'; import { components } from 'tg.service/billingApiSchema.generated'; import { PlanPublicChip } from '../../../component/Plan/PlanPublicChip'; -import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanSubscriptionCount'; 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'; +import { CloudPlanSubscriptionsTooltip } from 'tg.ee.module/billing/component/Plan/cloud/CloudPlanSubscriptionsTooltip'; 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,11 +145,15 @@ 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..f595b4a6bb 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'; @@ -22,10 +22,11 @@ import { BaseAdministrationView } from 'tg.views/administration/components/BaseA import { useMessage } from 'tg.hooks/useSuccessMessage'; import { confirmation } from 'tg.hooks/confirmation'; import { components } from 'tg.service/billingApiSchema.generated'; -import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanSubscriptionCount'; 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'; +import { SelfHostedPlanSubscriptionsTooltip } from 'tg.ee.module/billing/component/Plan/selfHosted/SelfHostedPlanSubscriptionsTooltip'; 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,10 +152,14 @@ export const AdministrationEePlansView = () => { label={t('administration_ee_plan_public_badge')} /> )} + - + diff --git a/webapp/src/ee/billing/component/Plan/BillingPeriodChip.tsx b/webapp/src/ee/billing/component/Plan/BillingPeriodChip.tsx new file mode 100644 index 0000000000..f84ec2d6d9 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/BillingPeriodChip.tsx @@ -0,0 +1,28 @@ +import { Chip } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type BillingPeriod = + components['schemas']['AdministrationBasicSubscriptionModel']['currentBillingPeriod']; + +export const BillingPeriodChip = ({ period }: { period?: BillingPeriod }) => { + const { t } = useTranslate(); + const config: Record, { label: string }> = { + MONTHLY: { label: t('subscription_period_monthly') }, + YEARLY: { label: t('subscription_period_yearly') }, + }; + + if (!period) return null; + + const periodConfig = config[period]; + if (!periodConfig) return null; + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/PlanSubscriptionsTooltip.tsx b/webapp/src/ee/billing/component/Plan/PlanSubscriptionsTooltip.tsx new file mode 100644 index 0000000000..6dc23ef7c7 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/PlanSubscriptionsTooltip.tsx @@ -0,0 +1,150 @@ +import { components } from 'tg.service/billingApiSchema.generated'; +import { + Box, + Button, + Chip, + Link, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanSubscriptionCount'; +import { UseInfiniteQueryResult } from 'react-query'; +import { HateoasListData } from 'tg.service/response.types'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { SubscriptionStatusChip } from './SubscriptionStatusChip'; +import { BillingPeriodChip } from './BillingPeriodChip'; +import { useTranslate } from '@tolgee/react'; + +type CloudPlanModel = components['schemas']['AdministrationCloudPlanModel']; +type SelfHostedPlanModel = + components['schemas']['SelfHostedEePlanAdministrationModel']; + +type SubscriptionModel = + components['schemas']['AdministrationBasicSubscriptionModel']; + +export const PlanSubscriptionsTooltip = ({ + plan, + subscriptions, + onOpen, +}: { + plan: CloudPlanModel | SelfHostedPlanModel; + subscriptions: UseInfiniteQueryResult>; + onOpen?: () => void; +}) => { + const { t } = useTranslate(); + const isLoading = subscriptions.isLoading; + const isFetchingNextPage = subscriptions.isFetchingNextPage; + const subscriptionItems = + subscriptions.data?.pages.flatMap((page) => page._embedded?.plans || []) || + []; + + if (!plan.subscriptionCount) { + return null; + } + + const tooltipTitle = ( + + {isLoading ? ( + + + + ) : ( + subscriptionItems.length && ( + + {subscriptionItems.map((item, index) => ( + + + + + {item.organization} + + + + {item.planName} + + + + + + {item.cancelAtPeriodEnd && ( + + )} + + + ))} + {subscriptions.hasNextPage && ( + + + + )} + + ) + )} + + ); + + return ( + + + + + + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/SubscriptionStatusChip.tsx b/webapp/src/ee/billing/component/Plan/SubscriptionStatusChip.tsx new file mode 100644 index 0000000000..11b9d59e3c --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/SubscriptionStatusChip.tsx @@ -0,0 +1,43 @@ +import { Chip, ChipProps } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type Status = + components['schemas']['AdministrationBasicSubscriptionModel']['status']; + +export const SubscriptionStatusChip = ({ status }: { status: Status }) => { + const { t } = useTranslate(); + + const STATUS_CONFIG: Record< + Status, + { + color: ChipProps['color']; + label: string; + } + > = { + ACTIVE: { color: 'success', label: t('subscription_status_active') }, + TRIALING: { color: 'info', label: t('subscription_status_trialing') }, + CANCELED: { color: 'default', label: t('subscription_status_canceled') }, + PAST_DUE: { color: 'warning', label: t('subscription_status_past_due') }, + UNPAID: { color: 'error', label: t('subscription_status_unpaid') }, + ERROR: { color: 'error', label: t('subscription_status_error') }, + KEY_USED_BY_ANOTHER_INSTANCE: { + color: 'default', + label: t('subscription_status_key_used_by_another_instance'), + }, + UNKNOWN: { color: 'default', label: t('subscription_status_unknown') }, + }; + + const config = STATUS_CONFIG[status]; + + if (!config) return null; + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/cloud/CloudPlanSubscriptionsTooltip.tsx b/webapp/src/ee/billing/component/Plan/cloud/CloudPlanSubscriptionsTooltip.tsx new file mode 100644 index 0000000000..7b2e8789af --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/cloud/CloudPlanSubscriptionsTooltip.tsx @@ -0,0 +1,45 @@ +import { useBillingApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { PlanSubscriptionsTooltip } from 'tg.ee.module/billing/component/Plan/PlanSubscriptionsTooltip'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { useState } from 'react'; + +type CloudPlanModel = components['schemas']['AdministrationCloudPlanModel']; + +export const CloudPlanSubscriptionsTooltip = ({ + plan, +}: { + plan: CloudPlanModel; +}) => { + const [enabled, setEnabled] = useState(false); + const query = { page: 0, size: 10 }; + const subscriptions = useBillingApiInfiniteQuery({ + url: '/v2/administration/billing/cloud-plans/{planId}/subscriptions', + method: 'get', + path: { planId: plan.id }, + query, + options: { + enabled: enabled, + keepPreviousData: true, + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + query: { ...query, page: lastPage.page.number! + 1 }, + path: { planId: plan.id }, + }; + } + return null; + }, + }, + }); + + return ( + setEnabled(true)} + /> + ); +}; 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/ee/billing/component/Plan/selfHosted/SelfHostedPlanSubscriptionsTooltip.tsx b/webapp/src/ee/billing/component/Plan/selfHosted/SelfHostedPlanSubscriptionsTooltip.tsx new file mode 100644 index 0000000000..3526340526 --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/selfHosted/SelfHostedPlanSubscriptionsTooltip.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { PlanSubscriptionsTooltip } from 'tg.ee.module/billing/component/Plan/PlanSubscriptionsTooltip'; +import { useBillingApiInfiniteQuery } from 'tg.service/http/useQueryApi'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type SelfHostedPlanModel = + components['schemas']['SelfHostedEePlanAdministrationModel']; + +export const SelfHostedPlanSubscriptionsTooltip = ({ + plan, +}: { + plan: SelfHostedPlanModel; +}) => { + const [enabled, setEnabled] = useState(false); + const queryBase = { + url: '/v2/administration/billing/self-hosted-ee-plans/{planId}/subscriptions', + method: 'get', + path: { planId: plan.id }, + query: { page: 0, size: 30 }, + } as const; + + const subscriptions = useBillingApiInfiniteQuery({ + ...queryBase, + options: { + enabled, + keepPreviousData: true, + getNextPageParam: (lastPage: any) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + ...queryBase, + query: { ...queryBase.query, page: lastPage.page.number! + 1 }, + }; + } + return null; + }, + }, + }); + + return ( + setEnabled(true)} + /> + ); +}; diff --git a/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx b/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx index dbb72881e3..3312bec5a7 100644 --- a/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx +++ b/webapp/src/ee/billing/currentCloudSubscription/CurrentCloudSubscriptionInfo.tsx @@ -18,6 +18,7 @@ import { SubscriptionsTrialAlert } from './subscriptionsTrialAlert/Subscriptions import { TrialInfo } from './TrialInfo'; import { getProgressData } from '../component/getProgressData'; import { SubscriptionMetrics } from './SubscriptionMetrics'; +import { PlanMigrationAlert } from './PlanMigrationAlert'; type CloudSubscriptionModel = billingComponents['schemas']['CloudSubscriptionModel']; @@ -66,6 +67,7 @@ export const CurrentCloudSubscriptionInfo: FC = ({ return ( <> + diff --git a/webapp/src/ee/billing/currentCloudSubscription/PlanMigrationAlert.tsx b/webapp/src/ee/billing/currentCloudSubscription/PlanMigrationAlert.tsx new file mode 100644 index 0000000000..fc2d3b06c6 --- /dev/null +++ b/webapp/src/ee/billing/currentCloudSubscription/PlanMigrationAlert.tsx @@ -0,0 +1,109 @@ +import { FC } from 'react'; +import { Box } from '@mui/material'; +import { T } from '@tolgee/react'; + +import { components as billingComponents } from 'tg.service/billingApiSchema.generated'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { Alert } from 'tg.component/common/Alert'; +import { useCurrentDate } from 'tg.hooks/useCurrentDate'; + +type CloudSubscriptionModel = + billingComponents['schemas']['CloudSubscriptionModel']; + +type PlanMigrationAlertData = { + variant: 'scheduled' | 'completed'; + from: string; + to: string; + date: number; +}; + +const getPeriodDuration = (subscription: CloudSubscriptionModel) => { + if ( + subscription.currentPeriodStart && + subscription.currentPeriodEnd && + subscription.currentPeriodEnd > subscription.currentPeriodStart + ) { + return subscription.currentPeriodEnd - subscription.currentPeriodStart; + } + + // 14 days + return 1000 * 60 * 60 * 24 * 14; +}; + +const getPlanMigrationAlertData = ( + subscription: CloudSubscriptionModel +): PlanMigrationAlertData | undefined => { + const migration = subscription.planMigration; + const date = useCurrentDate(); + + if (!migration) { + return undefined; + } + + const targetPlanName = migration.targetPlanName || subscription.plan.name; + const now = date.getTime(); + + if ( + migration.status === 'SCHEDULED' && + migration.scheduledAt && + migration.scheduledAt >= now && + !subscription.cancelAtPeriodEnd + ) { + return { + variant: 'scheduled', + from: migration.originPlanName, + to: targetPlanName, + date: migration.scheduledAt, + }; + } + + if ( + migration.status === 'COMPLETED' && + migration.finalizedAt && + migration.finalizedAt <= now + ) { + const periodDuration = getPeriodDuration(subscription); + if (now - migration.finalizedAt < periodDuration) { + return { + variant: 'completed', + from: migration.originPlanName, + to: targetPlanName, + date: migration.finalizedAt, + }; + } + } + + return undefined; +}; + +type Props = { + subscription: CloudSubscriptionModel; +}; + +export const PlanMigrationAlert: FC = ({ subscription }) => { + const formatDate = useDateFormatter(); + const migrationAlert = getPlanMigrationAlertData(subscription); + + if (!migrationAlert) { + return null; + } + + const params = { + from: migrationAlert.from, + to: migrationAlert.to, + date: formatDate(migrationAlert.date, { dateStyle: 'long' }), + b: , + }; + + return ( + + + {migrationAlert.variant === 'scheduled' ? ( + + ) : ( + + )} + + + ); +}; 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 = { > + + + + + + + + + + + + { - const differenceInSeconds = + const diffSeconds = (Date.now().valueOf() - new Date(timeInPast).valueOf()) / 1000; + const isFuture = diffSeconds < 0; + const differenceInSeconds = Math.abs(diffSeconds); if (differenceInSeconds < 60) { - return t('time_difference_right_now'); + return isFuture + ? t('time_difference_in_seconds', { + value: Math.trunc(differenceInSeconds), + }) + : t('time_difference_right_now'); } if (differenceInSeconds < 60 * 60) { - return t('time_difference_minutes', { - value: Math.trunc(differenceInSeconds / 60), - }); + const value = Math.trunc(differenceInSeconds / 60); + return isFuture + ? t('time_difference_in_minutes', { value }) + : t('time_difference_minutes', { value }); } if (differenceInSeconds < 60 * 60 * 24) { - return t('time_difference_hours', { - value: Math.trunc(differenceInSeconds / (60 * 60)), - }); + const value = Math.trunc(differenceInSeconds / (60 * 60)); + return isFuture + ? t('time_difference_in_hours', { value }) + : t('time_difference_hours', { value }); } if (differenceInSeconds < 60 * 60 * 24 * 30) { - return t('time_difference_days', { - value: Math.trunc(differenceInSeconds / (60 * 60 * 24)), - }); + const value = Math.trunc(differenceInSeconds / (60 * 60 * 24)); + return isFuture + ? t('time_difference_in_days', { value }) + : t('time_difference_days', { value }); } return formatDate(new Date(timeInPast), { dateStyle: 'medium' }); diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 8b1b7e469f..494c0ff207 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -2548,7 +2548,9 @@ export interface components { | "impersonation_of_admin_by_supporter_not_allowed" | "already_impersonating_user" | "operation_not_permitted_in_read_only_mode" - | "file_processing_failed"; + | "file_processing_failed" + | "plan_migration_not_found" + | "plan_has_migrations"; params?: unknown[]; }; ExistenceEntityDescription: { @@ -5816,7 +5818,9 @@ export interface components { | "impersonation_of_admin_by_supporter_not_allowed" | "already_impersonating_user" | "operation_not_permitted_in_read_only_mode" - | "file_processing_failed"; + | "file_processing_failed" + | "plan_migration_not_found" + | "plan_has_migrations"; params?: unknown[]; success: boolean; }; diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index 96ba9309d8..625e4c5200 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -19,6 +19,26 @@ export interface paths { get: operations["getPlans_1"]; post: operations["create_2"]; }; + "/v2/administration/billing/cloud-plans/migration": { + post: operations["createPlanMigration_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/email-preview": { + post: operations["sendPlanMigrationPreview_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/{migrationId}": { + get: operations["getPlanMigration_1"]; + put: operations["updatePlanMigration_1"]; + delete: operations["deletePlanMigration_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/{migrationId}/subscriptions": { + get: operations["getPlanMigrationSubscriptions_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/{migrationId}/upcoming-subscriptions": { + get: operations["getPlanMigrationUpcomingSubscriptions_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/{migrationId}/upcoming-subscriptions/{subscriptionId}/skip": { + put: operations["setUpcomingSubscriptionSkipped_1"]; + }; "/v2/administration/billing/cloud-plans/{planId}": { get: operations["getPlan_1"]; put: operations["updatePlan_1"]; @@ -30,6 +50,9 @@ export interface paths { "/v2/administration/billing/cloud-plans/{planId}/organizations": { get: operations["getPlanOrganizations_1"]; }; + "/v2/administration/billing/cloud-plans/{planId}/subscriptions": { + get: operations["planSubscriptions_1"]; + }; "/v2/administration/billing/features": { get: operations["getAllFeatures"]; }; @@ -40,10 +63,33 @@ export interface paths { "/v2/administration/billing/organizations": { get: operations["getOrganizations"]; }; + "/v2/administration/billing/plan-migration/email-template": { + get: operations["getPlanMigrationEmailTemplate"]; + }; "/v2/administration/billing/self-hosted-ee-plans": { get: operations["getPlans"]; post: operations["create_1"]; }; + "/v2/administration/billing/self-hosted-ee-plans/migration": { + post: operations["createPlanMigration"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/email-preview": { + post: operations["sendPlanMigrationPreview"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}": { + get: operations["getPlanMigration"]; + put: operations["updatePlanMigration"]; + delete: operations["deletePlanMigration"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/subscriptions": { + get: operations["getPlanMigrationSubscriptions"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/upcoming-subscriptions": { + get: operations["getPlanMigrationUpcomingSubscriptions"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/upcoming-subscriptions/{subscriptionId}/skip": { + put: operations["setUpcomingSubscriptionSkipped"]; + }; "/v2/administration/billing/self-hosted-ee-plans/{planId}": { get: operations["getPlan"]; put: operations["updatePlan"]; @@ -55,6 +101,9 @@ export interface paths { "/v2/administration/billing/self-hosted-ee-plans/{planId}/organizations": { get: operations["getPlanOrganizations"]; }; + "/v2/administration/billing/self-hosted-ee-plans/{planId}/subscriptions": { + get: operations["planSubscriptions"]; + }; "/v2/administration/billing/stripe-products": { get: operations["getStripeProducts"]; }; @@ -207,6 +256,9 @@ export interface paths { "/v2/public/licensing/subscription": { post: operations["getMySubscription"]; }; + "/v2/public/llm/prompt": { + post: operations["prompt"]; + }; "/v2/public/telemetry/report": { post: operations["report"]; }; @@ -217,7 +269,26 @@ export interface paths { export interface components { schemas: { + AdministrationBasicSubscriptionModel: { + cancelAtPeriodEnd?: boolean; + /** @enum {string} */ + currentBillingPeriod?: "MONTHLY" | "YEARLY"; + organization: string; + organizationSlug: string; + planName: string; + /** @enum {string} */ + status: + | "ACTIVE" + | "CANCELED" + | "PAST_DUE" + | "UNPAID" + | "ERROR" + | "TRIALING" + | "KEY_USED_BY_ANOTHER_INSTANCE" + | "UNKNOWN"; + }; AdministrationCloudPlanModel: { + activeMigration?: boolean; /** Format: date-time */ archivedAt?: string; canEditPrices: boolean; @@ -256,6 +327,8 @@ export interface components { includedUsage: components["schemas"]["PlanIncludedUsageModel"]; /** @enum {string} */ metricType: "KEYS_SEATS" | "STRINGS"; + /** Format: int64 */ + migrationId?: number; name: string; nonCommercial: boolean; prices: components["schemas"]["PlanPricesModel"]; @@ -280,6 +353,7 @@ export interface components { /** Format: int64 */ organizationId: number; plan: components["schemas"]["AdministrationCloudPlanModel"]; + planMigration?: components["schemas"]["CloudSubscriptionPlanMigrationRecordModel"]; /** @enum {string} */ status: | "ACTIVE" @@ -294,6 +368,20 @@ export interface components { /** Format: int64 */ trialEnd?: number; }; + AdministrationSelfHostedEePlanMigrationModel: { + customEmailBody?: string; + enabled: boolean; + /** Format: int64 */ + id: number; + /** Format: int32 */ + monthlyOffsetDays: number; + sourcePlan: components["schemas"]["SelfHostedEePlanModel"]; + /** Format: int32 */ + subscriptionsCount?: number; + targetPlan: components["schemas"]["SelfHostedEePlanModel"]; + /** Format: int32 */ + yearlyOffsetDays: number; + }; AssignCloudPlanRequest: { customPlan?: components["schemas"]["CloudPlanRequest"]; /** Format: int64 */ @@ -342,6 +430,20 @@ export interface components { CancelLocalSubscriptionsRequest: { ids: components["schemas"]["SubscriptionId"][]; }; + CloudPlanMigrationModel: { + customEmailBody?: string; + enabled: boolean; + /** Format: int64 */ + id: number; + /** Format: int32 */ + monthlyOffsetDays: number; + sourcePlan: components["schemas"]["CloudPlanModel"]; + /** Format: int32 */ + subscriptionsCount?: number; + targetPlan: components["schemas"]["CloudPlanModel"]; + /** Format: int32 */ + yearlyOffsetDays: number; + }; CloudPlanModel: { /** Format: date-time */ archivedAt?: string; @@ -450,6 +552,7 @@ export interface components { /** Format: int64 */ organizationId: number; plan: components["schemas"]["CloudPlanModel"]; + planMigration?: components["schemas"]["CloudSubscriptionPlanMigrationRecordModel"]; /** @enum {string} */ status: | "ACTIVE" @@ -463,6 +566,16 @@ export interface components { /** Format: int64 */ trialEnd?: number; }; + CloudSubscriptionPlanMigrationRecordModel: { + /** Format: int64 */ + finalizedAt?: number; + originPlanName: string; + /** Format: int64 */ + scheduledAt?: number; + /** @enum {string} */ + status: "COMPLETED" | "SCHEDULED" | "SKIPPED"; + targetPlanName: string; + }; CollectionModelAdministrationCloudPlanModel: { _embedded?: { plans?: components["schemas"]["AdministrationCloudPlanModel"][]; @@ -503,6 +616,18 @@ export interface components { stripeProducts?: components["schemas"]["StripeProductModel"][]; }; }; + CreatePlanMigrationRequest: { + customEmailBody?: string; + enabled: boolean; + /** Format: int32 */ + monthlyOffsetDays: number; + /** Format: int64 */ + sourcePlanId: number; + /** Format: int64 */ + targetPlanId: number; + /** Format: int32 */ + yearlyOffsetDays: number; + }; CreateTaskRequest: { assignees: number[]; description: string; @@ -553,6 +678,15 @@ export interface components { seats: components["schemas"]["CurrentUsageItemModel"]; strings: components["schemas"]["CurrentUsageItemModel"]; }; + EmailPlaceholderModel: { + description: string; + exampleValue: string; + placeholder: string; + }; + EmailTemplateModel: { + body: string; + placeholders: components["schemas"]["EmailPlaceholderModel"][]; + }; ErrorResponseBody: { code: string; params?: unknown[]; @@ -860,7 +994,13 @@ export interface components { | "suggestion_cant_be_plural" | "suggestion_must_be_plural" | "duplicate_suggestion" - | "unsupported_media_type"; + | "unsupported_media_type" + | "impersonation_of_admin_by_supporter_not_allowed" + | "already_impersonating_user" + | "operation_not_permitted_in_read_only_mode" + | "file_processing_failed" + | "plan_migration_not_found" + | "plan_has_migrations"; params?: unknown[]; }; ExampleItem: { @@ -911,6 +1051,7 @@ export interface components { /** @description The Total amount with tax */ total: number; }; + JsonNode: unknown; LegacyTolgeeTranslateRequest: { /** @enum {string} */ formality?: "FORMAL" | "INFORMAL" | "DEFAULT"; @@ -939,6 +1080,18 @@ export interface components { */ limit: number; }; + LlmMessage: { + image?: string; + text?: string; + /** @enum {string} */ + type: "TEXT" | "IMAGE"; + }; + LlmParams: { + messages: components["schemas"]["LlmMessage"][]; + /** @enum {string} */ + priority: "LOW" | "HIGH"; + shouldOutputJson: boolean; + }; Metadata: { closeItems: components["schemas"]["ExampleItem"][]; examples: components["schemas"]["ExampleItem"][]; @@ -975,6 +1128,12 @@ export interface components { /** Format: int64 */ totalPages?: number; }; + PagedModelAdministrationBasicSubscriptionModel: { + _embedded?: { + plans?: components["schemas"]["AdministrationBasicSubscriptionModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelInvoiceModel: { _embedded?: { invoices?: components["schemas"]["InvoiceModel"][]; @@ -987,6 +1146,18 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; + PagedModelPlanMigrationRecordModel: { + _embedded?: { + planMigrationRecordModelList?: components["schemas"]["PlanMigrationRecordModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + PagedModelPlanMigrationUpcomingSubscriptionModel: { + _embedded?: { + planMigrationUpcomingSubscriptionModelList?: components["schemas"]["PlanMigrationUpcomingSubscriptionModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelSimpleOrganizationModel: { _embedded?: { organizations?: components["schemas"]["SimpleOrganizationModel"][]; @@ -1058,6 +1229,7 @@ export interface components { | "prompts.edit" | "translation-labels.manage" | "translation-labels.assign" + | "all.view" )[]; /** * @description List of languages user can change state to. If null, changing state of all language values is permitted. @@ -1117,6 +1289,53 @@ export interface components { /** Format: int64 */ translations: number; }; + PlanMigrationEmailPreviewRequest: { + customEmailBody?: string; + /** Format: int64 */ + sourcePlanId: number; + /** Format: int64 */ + targetPlanId: number; + }; + PlanMigrationRecordModel: { + /** Format: int64 */ + finalizedAt?: number; + organizationName: string; + organizationSlug: string; + originPlan: string; + plan: string; + /** Format: int64 */ + scheduledAt: number; + /** @enum {string} */ + status: "COMPLETED" | "SCHEDULED" | "SKIPPED"; + /** Format: int64 */ + transferAt: number; + }; + PlanMigrationRequest: { + customEmailBody?: string; + enabled: boolean; + /** Format: int32 */ + monthlyOffsetDays: number; + /** Format: int64 */ + targetPlanId: number; + /** Format: int32 */ + yearlyOffsetDays: number; + }; + PlanMigrationSkipRequest: { + skipped: boolean; + }; + PlanMigrationUpcomingSubscriptionModel: { + /** Format: int64 */ + currentPeriodEnd: number; + organizationName: string; + organizationSlug: string; + originPlan: string; + /** Format: int64 */ + scheduleAt: number; + skipped: boolean; + /** Format: int64 */ + subscriptionId: number; + targetPlan: string; + }; PlanPricesModel: { perSeat: number; perThousandKeys: number; @@ -1156,6 +1375,13 @@ export interface components { /** Format: int64 */ outputTokens?: number; }; + PromptResult: { + parsedJson?: components["schemas"]["JsonNode"]; + /** Format: int32 */ + price: number; + response: string; + usage?: components["schemas"]["PromptResponseUsageDto"]; + }; ReleaseKeyDto: { licenseKey: string; }; @@ -1184,6 +1410,7 @@ export interface components { planId: number; }; SelfHostedEePlanAdministrationModel: { + activeMigration?: boolean; /** Format: date-time */ archivedAt?: string; canEditPrices: boolean; @@ -1221,6 +1448,8 @@ export interface components { id: number; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; isPayAsYouGo: boolean; + /** Format: int64 */ + migrationId?: number; name: string; nonCommercial: boolean; prices: components["schemas"]["PlanPricesModel"]; @@ -1628,13 +1857,769 @@ export interface operations { filterAssignableToOrganization?: number; filterPlanIds?: number[]; filterPublic?: boolean; + filterHasMigration?: boolean; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelAdministrationCloudPlanModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + create_2: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloudPlanRequest"]; + }; + }; + }; + createPlanMigration_1: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CloudPlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePlanMigrationRequest"]; + }; + }; + }; + sendPlanMigrationPreview_1: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationEmailPreviewRequest"]; + }; + }; + }; + getPlanMigration_1: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CloudPlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + updatePlanMigration_1: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CloudPlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationRequest"]; + }; + }; + }; + deletePlanMigration_1: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getPlanMigrationSubscriptions_1: { + parameters: { + path: { + migrationId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelPlanMigrationRecordModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getPlanMigrationUpcomingSubscriptions_1: { + parameters: { + path: { + migrationId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelPlanMigrationUpcomingSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + setUpcomingSubscriptionSkipped_1: { + parameters: { + path: { + migrationId: number; + subscriptionId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationSkipRequest"]; + }; + }; + }; + getPlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + updatePlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloudPlanRequest"]; + }; + }; + }; + deletePlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + archivePlan_1: { + parameters: { + path: { + planId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getPlanOrganizations_1: { + parameters: { + path: { + planId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelSimpleOrganizationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + planSubscriptions_1: { + parameters: { + path: { + planId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelAdministrationBasicSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getAllFeatures: { + responses: { + /** OK */ + 200: { + content: { + "application/json": ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + | "PROJECT_LEVEL_CONTENT_STORAGES" + | "WEBHOOKS" + | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" + | "AI_PROMPT_CUSTOMIZATION" + | "SLACK_INTEGRATION" + | "TASKS" + | "SSO" + | "ORDER_TRANSLATION" + | "GLOSSARY" + | "TRANSLATION_LABELS" + )[]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + /** Returns active cloud subscriptions, which have inconsistent state in Tolgee and Stripe */ + getInconsistentSubscriptions: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelInconsistentSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getOrganizations: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + withCloudPlanId?: number; + hasSelfHostedSubscription?: boolean; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelAdministrationCloudPlanModel"]; + "application/json": components["schemas"]["PagedModelOrganizationWithSubscriptionsModel"]; }; }; /** Bad Request */ @@ -1663,12 +2648,12 @@ export interface operations { }; }; }; - create_2: { + getPlanMigrationEmailTemplate: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + "application/json": components["schemas"]["EmailTemplateModel"]; }; }; /** Bad Request */ @@ -1696,23 +2681,28 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["CloudPlanRequest"]; - }; - }; }; - getPlan_1: { + getPlans: { parameters: { - path: { - planId: number; + query: { + /** + * Filters only plans which can be assignable to the provided organization it. + * + * Plan can be assignable to organization because of one of these reasons: + * - plan is private free, visible to organization + * - plan is paid (Assignable as trial) + */ + filterAssignableToOrganization?: number; + filterPlanIds?: number[]; + filterPublic?: boolean; + filterHasMigration?: boolean; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + "application/json": components["schemas"]["CollectionModelSelfHostedEePlanAdministrationModel"]; }; }; /** Bad Request */ @@ -1741,17 +2731,12 @@ export interface operations { }; }; }; - updatePlan_1: { - parameters: { - path: { - planId: number; - }; - }; + create_1: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["AdministrationCloudPlanModel"]; + "application/json": components["schemas"]["SelfHostedEePlanAdministrationModel"]; }; }; /** Bad Request */ @@ -1781,19 +2766,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CloudPlanRequest"]; + "application/json": components["schemas"]["SelfHostedEePlanRequest"]; }; }; }; - deletePlan_1: { - parameters: { - path: { - planId: number; - }; - }; + createPlanMigration: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["AdministrationSelfHostedEePlanMigrationModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -1819,20 +2803,16 @@ export interface operations { }; }; }; - }; - archivePlan_1: { - parameters: { - path: { - planId: number; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePlanMigrationRequest"]; }; }; + }; + sendPlanMigrationPreview: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["AdministrationCloudPlanModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -1858,27 +2838,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationEmailPreviewRequest"]; + }; + }; }; - getPlanOrganizations_1: { + getPlanMigration: { parameters: { path: { - planId: number; - }; - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - search?: string; + migrationId: number; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelSimpleOrganizationModel"]; + "application/json": components["schemas"]["AdministrationSelfHostedEePlanMigrationModel"]; }; }; /** Bad Request */ @@ -1907,33 +2883,17 @@ export interface operations { }; }; }; - getAllFeatures: { + updatePlanMigration: { + parameters: { + path: { + migrationId: number; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - | "PROJECT_LEVEL_CONTENT_STORAGES" - | "WEBHOOKS" - | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" - | "AI_PROMPT_CUSTOMIZATION" - | "SLACK_INTEGRATION" - | "TASKS" - | "SSO" - | "ORDER_TRANSLATION" - | "GLOSSARY" - | "TRANSLATION_LABELS" - )[]; + "application/json": components["schemas"]["AdministrationSelfHostedEePlanMigrationModel"]; }; }; /** Bad Request */ @@ -1961,16 +2921,21 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationRequest"]; + }; + }; }; - /** Returns active cloud subscriptions, which have inconsistent state in Tolgee and Stripe */ - getInconsistentSubscriptions: { + deletePlanMigration: { + parameters: { + path: { + migrationId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["CollectionModelInconsistentSubscriptionModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -1997,8 +2962,11 @@ export interface operations { }; }; }; - getOrganizations: { + getPlanMigrationSubscriptions: { parameters: { + path: { + migrationId: number; + }; query: { /** Zero-based page index (0..N) */ page?: number; @@ -2006,16 +2974,13 @@ export interface operations { size?: number; /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ sort?: string[]; - search?: string; - withCloudPlanId?: number; - hasSelfHostedSubscription?: boolean; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelOrganizationWithSubscriptionsModel"]; + "application/json": components["schemas"]["PagedModelPlanMigrationRecordModel"]; }; }; /** Bad Request */ @@ -2044,26 +3009,25 @@ export interface operations { }; }; }; - getPlans: { + getPlanMigrationUpcomingSubscriptions: { parameters: { + path: { + migrationId: number; + }; query: { - /** - * Filters only plans which can be assignable to the provided organization it. - * - * Plan can be assignable to organization because of one of these reasons: - * - plan is private free, visible to organization - * - plan is paid (Assignable as trial) - */ - filterAssignableToOrganization?: number; - filterPlanIds?: number[]; - filterPublic?: boolean; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; }; }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelSelfHostedEePlanAdministrationModel"]; + "application/json": components["schemas"]["PagedModelPlanMigrationUpcomingSubscriptionModel"]; }; }; /** Bad Request */ @@ -2092,14 +3056,16 @@ export interface operations { }; }; }; - create_1: { + setUpcomingSubscriptionSkipped: { + parameters: { + path: { + migrationId: number; + subscriptionId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SelfHostedEePlanAdministrationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -2127,7 +3093,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SelfHostedEePlanRequest"]; + "application/json": components["schemas"]["PlanMigrationSkipRequest"]; }; }; }; @@ -2336,6 +3302,53 @@ export interface operations { }; }; }; + planSubscriptions: { + parameters: { + path: { + planId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelAdministrationBasicSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; getStripeProducts: { responses: { /** OK */ @@ -4357,6 +5370,45 @@ export interface operations { }; }; }; + prompt: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PromptResult"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LlmParams"]; + }; + }; + }; report: { responses: { /** OK */ diff --git a/webapp/src/service/http/useQueryApi.ts b/webapp/src/service/http/useQueryApi.ts index a472aee9b8..af377f2cff 100644 --- a/webapp/src/service/http/useQueryApi.ts +++ b/webapp/src/service/http/useQueryApi.ts @@ -292,6 +292,13 @@ export const useBillingApiQuery = < props: QueryProps ) => useApiQuery(props); +export const useBillingApiInfiniteQuery = < + Url extends keyof billingPaths, + Method extends keyof billingPaths[Url] +>( + props: InfiniteQueryProps +) => useApiInfiniteQuery(props); + export const useBillingApiMutation = < Url extends keyof billingPaths, Method extends keyof billingPaths[Url] diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index cccd4083f3..24b48353d7 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -109,6 +109,10 @@ export function useErrorTranslation() { return t('content_storage_is_in_use'); case 'plan_has_subscribers': return t('plan_has_subscribers'); + case 'plan_has_migrations': + return t('plan_has_migrations'); + case 'plan_migration_not_found': + return t('plan_migration_not_found'); case 'cannot_store_file_to_content_storage': return t('cannot_store_file_to_content_storage'); case 'unexpected_error_while_publishing_to_content_storage':