Flowise/packages/ui/src/views/account/index.jsx

1337 lines
71 KiB
JavaScript

import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
// utils
import useNotifier from '@/utils/useNotifier'
import { validatePassword } from '@/utils/validation'
// material-ui
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
LinearProgress,
OutlinedInput,
Skeleton,
Stack,
TextField,
Typography
} from '@mui/material'
import { darken, useTheme } from '@mui/material/styles'
// project imports
import ErrorBoundary from '@/ErrorBoundary'
import ViewHeader from '@/layout/MainLayout/ViewHeader'
import { StyledButton } from '@/ui-component/button/StyledButton'
import MainCard from '@/ui-component/cards/MainCard'
import SettingsSection from '@/ui-component/form/settings'
import PricingDialog from '@/ui-component/subscription/PricingDialog'
// Icons
import { IconAlertCircle, IconCreditCard, IconExternalLink, IconSparkles, IconX } from '@tabler/icons-react'
// API
import accountApi from '@/api/account.api'
import pricingApi from '@/api/pricing'
import userApi from '@/api/user'
// Hooks
import useApi from '@/hooks/useApi'
// Store
import { store } from '@/store'
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
import { gridSpacing } from '@/store/constant'
import { useConfig } from '@/store/context/ConfigContext'
import { useError } from '@/store/context/ErrorContext'
import { userProfileUpdated } from '@/store/reducers/authSlice'
// ==============================|| ACCOUNT SETTINGS ||============================== //
const calculatePercentage = (count, total) => {
return Math.min((count / total) * 100, 100)
}
const AccountSettings = () => {
const theme = useTheme()
const dispatch = useDispatch()
useNotifier()
const navigate = useNavigate()
const currentUser = useSelector((state) => state.auth.user)
const customization = useSelector((state) => state.customization)
const { error, setError } = useError()
const { isCloud } = useConfig()
const [isLoading, setLoading] = useState(true)
const [profileName, setProfileName] = useState('')
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [usage, setUsage] = useState(null)
const [isBillingLoading, setIsBillingLoading] = useState(false)
const [seatsQuantity, setSeatsQuantity] = useState(0)
const [prorationInfo, setProrationInfo] = useState(null)
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
const [openPricingDialog, setOpenPricingDialog] = useState(false)
const [openRemoveSeatsDialog, setOpenRemoveSeatsDialog] = useState(false)
const [openAddSeatsDialog, setOpenAddSeatsDialog] = useState(false)
const [includedSeats, setIncludedSeats] = useState(0)
const [purchasedSeats, setPurchasedSeats] = useState(0)
const [occupiedSeats, setOccupiedSeats] = useState(0)
const [totalSeats, setTotalSeats] = useState(0)
const predictionsUsageInPercent = useMemo(() => {
return usage ? calculatePercentage(usage.predictions?.usage, usage.predictions?.limit) : 0
}, [usage])
const storageUsageInPercent = useMemo(() => {
return usage ? calculatePercentage(usage.storage?.usage, usage.storage?.limit) : 0
}, [usage])
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
const getUserByIdApi = useApi(userApi.getUserById)
const getPricingPlansApi = useApi(pricingApi.getPricingPlans)
const getAdditionalSeatsQuantityApi = useApi(userApi.getAdditionalSeatsQuantity)
const getAdditionalSeatsProrationApi = useApi(userApi.getAdditionalSeatsProration)
const getCustomerDefaultSourceApi = useApi(userApi.getCustomerDefaultSource)
const updateAdditionalSeatsApi = useApi(userApi.updateAdditionalSeats)
const getCurrentUsageApi = useApi(userApi.getCurrentUsage)
useEffect(() => {
if (isCloud) {
getUserByIdApi.request(currentUser.id)
getPricingPlansApi.request()
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
getCurrentUsageApi.request()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCloud])
useEffect(() => {
setLoading(getUserByIdApi.loading)
}, [getUserByIdApi.loading])
useEffect(() => {
try {
if (getUserByIdApi.data) {
setProfileName(getUserByIdApi.data?.name || '')
setEmail(getUserByIdApi.data?.email || '')
}
} catch (e) {
console.error(e)
}
}, [getUserByIdApi.data])
useEffect(() => {
if (getCurrentUsageApi.data) {
setUsage(getCurrentUsageApi.data)
}
}, [getCurrentUsageApi.data])
useEffect(() => {
if (openRemoveSeatsDialog || openAddSeatsDialog) {
setSeatsQuantity(0)
getCustomerDefaultSourceApi.request(currentUser?.activeOrganizationCustomerId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openRemoveSeatsDialog, openAddSeatsDialog])
useEffect(() => {
if (getAdditionalSeatsProrationApi.data) {
setProrationInfo(getAdditionalSeatsProrationApi.data)
}
}, [getAdditionalSeatsProrationApi.data])
useEffect(() => {
if (!getAdditionalSeatsQuantityApi.loading && getAdditionalSeatsQuantityApi.data) {
const included = getAdditionalSeatsQuantityApi.data?.includedSeats || 1
const purchased = getAdditionalSeatsQuantityApi.data?.quantity || 0
const occupied = getAdditionalSeatsQuantityApi.data?.totalOrgUsers || 1
setIncludedSeats(included)
setPurchasedSeats(purchased)
setOccupiedSeats(occupied)
setTotalSeats(included + purchased)
}
}, [getAdditionalSeatsQuantityApi.data, getAdditionalSeatsQuantityApi.loading])
const currentPlanTitle = useMemo(() => {
if (!getPricingPlansApi.data) return ''
const currentPlan = getPricingPlansApi.data.find((plan) => plan.prodId === currentUser?.activeOrganizationProductId)
return currentPlan?.title || ''
}, [getPricingPlansApi.data, currentUser?.activeOrganizationProductId])
const handleBillingPortalClick = async () => {
setIsBillingLoading(true)
try {
const resp = await accountApi.getBillingData()
if (resp.data?.url) {
window.open(resp.data.url, '_blank')
}
} catch (error) {
enqueueSnackbar({
message: 'Failed to access billing portal',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
} finally {
setIsBillingLoading(false)
}
}
const saveProfileData = async () => {
try {
const obj = {
id: currentUser.id,
name: profileName,
email: email
}
const saveProfileResp = await userApi.updateUser(obj)
if (saveProfileResp.data) {
store.dispatch(userProfileUpdated(saveProfileResp.data))
enqueueSnackbar({
message: 'Profile updated',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
} catch (error) {
setError(error)
enqueueSnackbar({
message: `Failed to update profile: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
const savePassword = async () => {
try {
const validationErrors = []
if (newPassword !== confirmPassword) {
validationErrors.push('New Password and Confirm Password do not match')
}
const passwordErrors = validatePassword(newPassword)
if (passwordErrors.length > 0) {
validationErrors.push(...passwordErrors)
}
if (validationErrors.length > 0) {
enqueueSnackbar({
message: validationErrors.join(', '),
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
return
}
const obj = {
id: currentUser.id,
password: newPassword
}
const saveProfileResp = await userApi.updateUser(obj)
if (saveProfileResp.data) {
store.dispatch(userProfileUpdated(saveProfileResp.data))
enqueueSnackbar({
message: 'Password updated',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
} catch (error) {
setError(error)
enqueueSnackbar({
message: `Failed to update password: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
const handleSeatsModification = async (newSeatsAmount) => {
try {
setIsUpdatingSeats(true)
if (!prorationInfo?.prorationDate) {
throw new Error('No proration date available')
}
await updateAdditionalSeatsApi.request(
currentUser?.activeOrganizationSubscriptionId,
newSeatsAmount,
prorationInfo.prorationDate
)
enqueueSnackbar({
message: 'Seats updated successfully',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
// Refresh the seats quantity display
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
} catch (error) {
console.error('Error updating seats:', error)
enqueueSnackbar({
message: `Failed to update seats: ${
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
} finally {
setIsUpdatingSeats(false)
setProrationInfo(null)
setOpenAddSeatsDialog(false)
setOpenRemoveSeatsDialog(false)
setSeatsQuantity(0)
}
}
const handleQuantityChange = (value, operation) => {
setSeatsQuantity(value)
// Calculate proration for the new quantity
const totalAdditionalSeats = operation === 'add' ? purchasedSeats + value : purchasedSeats - value
if (currentUser?.activeOrganizationSubscriptionId) {
getAdditionalSeatsProrationApi.request(currentUser.activeOrganizationSubscriptionId, totalAdditionalSeats)
}
}
const handleRemoveSeatsDialogClose = () => {
if (!isUpdatingSeats) {
setProrationInfo(null)
setOpenRemoveSeatsDialog(false)
setSeatsQuantity(0)
}
}
const handleAddSeatsDialogClose = () => {
if (!isUpdatingSeats) {
setProrationInfo(null)
setOpenAddSeatsDialog(false)
setSeatsQuantity(0)
}
}
// Calculate empty seats
const emptySeats = Math.min(purchasedSeats, totalSeats - occupiedSeats)
return (
<MainCard maxWidth='md'>
{error ? (
<ErrorBoundary error={error} />
) : (
<Stack flexDirection='column' sx={{ gap: 4 }}>
<ViewHeader title='Account Settings' />
{isLoading && !getUserByIdApi.data ? (
<Box display='flex' flexDirection='column' gap={gridSpacing}>
<Skeleton width='25%' height={32} />
<Box display='flex' flexDirection='column' gap={2}>
<Skeleton width='20%' />
<Skeleton variant='rounded' height={56} />
</Box>
<Box display='flex' flexDirection='column' gap={2}>
<Skeleton width='20%' />
<Skeleton variant='rounded' height={56} />
</Box>
<Box display='flex' flexDirection='column' gap={2}>
<Skeleton width='20%' />
<Skeleton variant='rounded' height={56} />
</Box>
</Box>
) : (
<>
<SettingsSection title='Subscription & Billing'>
<Box
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)'
}}
>
<Box
sx={{
gridColumn: 'span 2 / span 2',
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
justifyContent: 'center',
gap: 1,
px: 2.5,
py: 2
}}
>
{currentPlanTitle && (
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Current Organization Plan:</Typography>
<Typography sx={{ ml: 1, color: theme.palette.success.dark }} variant='h3'>
{currentPlanTitle.toUpperCase()}
</Typography>
</Stack>
)}
<Typography
sx={{ opacity: customization.isDarkMode ? 0.7 : 1 }}
variant='body2'
color='text.secondary'
>
Update your billing details and subscription
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
px: 2.5,
py: 2,
gap: 2
}}
>
<Button
variant='outlined'
endIcon={!isBillingLoading && <IconExternalLink />}
disabled={!currentUser.isOrganizationAdmin || isBillingLoading}
onClick={handleBillingPortalClick}
sx={{ borderRadius: 2, height: 40 }}
>
{isBillingLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color='inherit' />
Loading
</Box>
) : (
'Billing'
)}
</Button>
<Button
variant='contained'
sx={{
mr: 1,
ml: 2,
minWidth: 160,
height: 40,
borderRadius: 15,
background: (theme) =>
`linear-gradient(90deg, ${theme.palette.primary.main} 10%, ${theme.palette.secondary.main} 100%)`,
color: (theme) => theme.palette.secondary.contrastText,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
transition: 'all 0.3s ease',
'&:hover': {
background: (theme) =>
`linear-gradient(90deg, ${darken(theme.palette.primary.main, 0.1)} 10%, ${darken(
theme.palette.secondary.main,
0.1
)} 100%)`,
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
}
}}
endIcon={<IconSparkles />}
disabled={!currentUser.isOrganizationAdmin}
onClick={() => setOpenPricingDialog(true)}
>
Change Plan
</Button>
</Box>
</Box>
</SettingsSection>
<SettingsSection title='Seats'>
<Box
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)'
}}
>
<Box
sx={{
gridColumn: 'span 2 / span 2',
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
justifyContent: 'center',
gap: 1,
px: 2.5,
py: 2
}}
>
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Seats Included in Plan:</Typography>
<Typography sx={{ ml: 1, color: 'inherit' }} variant='h3'>
{getAdditionalSeatsQuantityApi.loading ? <CircularProgress size={16} /> : includedSeats}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Additional Seats Purchased:</Typography>
<Typography sx={{ ml: 1, color: theme.palette.success.dark }} variant='h3'>
{getAdditionalSeatsQuantityApi.loading ? <CircularProgress size={16} /> : purchasedSeats}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
<Typography variant='body2'>Occupied Seats:</Typography>
<Typography sx={{ ml: 1, color: 'inherit' }} variant='h3'>
{getAdditionalSeatsQuantityApi.loading ? (
<CircularProgress size={16} />
) : (
`${occupiedSeats}/${totalSeats}`
)}
</Typography>
</Stack>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
gap: 2,
px: 2.5,
py: 2
}}
>
{getAdditionalSeatsQuantityApi.data?.quantity > 0 && currentPlanTitle.toUpperCase() === 'PRO' && (
<Button
variant='outlined'
disabled={!currentUser.isOrganizationAdmin || !getAdditionalSeatsQuantityApi.data?.quantity}
onClick={() => {
setOpenRemoveSeatsDialog(true)
}}
color='error'
sx={{ borderRadius: 2, height: 40 }}
>
Remove Seats
</Button>
)}
<StyledButton
variant='contained'
disabled={!currentUser.isOrganizationAdmin}
onClick={() => {
if (currentPlanTitle.toUpperCase() === 'PRO') {
setOpenAddSeatsDialog(true)
} else {
setOpenPricingDialog(true)
}
}}
title='Add Seats is available only for PRO plan'
sx={{ borderRadius: 2, height: 40 }}
>
Add Seats
</StyledButton>
</Box>
</Box>
</SettingsSection>
<SettingsSection title='Usage'>
<Box
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)'
}}
>
<Box sx={{ p: 2.5, borderRight: 1, borderColor: theme.palette.grey[900] + 25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant='h3'>Predictions</Typography>
<Typography variant='body2' color='text.secondary'>
{`${usage?.predictions?.usage || 0} / ${usage?.predictions?.limit || 0}`}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2 }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress
sx={{
height: 10,
borderRadius: 5,
'& .MuiLinearProgress-bar': {
backgroundColor: (theme) => {
if (predictionsUsageInPercent > 90) return theme.palette.error.main
if (predictionsUsageInPercent > 75) return theme.palette.warning.main
if (predictionsUsageInPercent > 50) return theme.palette.success.light
return theme.palette.success.main
}
}
}}
value={predictionsUsageInPercent > 100 ? 100 : predictionsUsageInPercent}
variant='determinate'
/>
</Box>
<Typography variant='body2' color='text.secondary'>{`${predictionsUsageInPercent.toFixed(
2
)}%`}</Typography>
</Box>
</Box>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant='h3'>Storage</Typography>
<Typography variant='body2' color='text.secondary'>
{`${(usage?.storage?.usage || 0).toFixed(2)}MB / ${(usage?.storage?.limit || 0).toFixed(
2
)}MB`}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2 }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress
sx={{
height: 10,
borderRadius: 5,
'& .MuiLinearProgress-bar': {
backgroundColor: (theme) => {
if (storageUsageInPercent > 90) return theme.palette.error.main
if (storageUsageInPercent > 75) return theme.palette.warning.main
if (storageUsageInPercent > 50) return theme.palette.success.light
return theme.palette.success.main
}
}
}}
value={storageUsageInPercent > 100 ? 100 : storageUsageInPercent}
variant='determinate'
/>
</Box>
<Typography variant='body2' color='text.secondary'>{`${storageUsageInPercent.toFixed(
2
)}%`}</Typography>
</Box>
</Box>
</Box>
</SettingsSection>
<SettingsSection
action={
<StyledButton onClick={saveProfileData} sx={{ borderRadius: 2, height: 40 }} variant='contained'>
Save
</StyledButton>
}
title='Profile'
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: gridSpacing,
px: 2.5,
py: 2
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant='body1'>Name</Typography>
<OutlinedInput
id='name'
type='string'
fullWidth
placeholder='Your Name'
name='name'
onChange={(e) => setProfileName(e.target.value)}
value={profileName}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant='body1'>Email Address</Typography>
<OutlinedInput
id='email'
type='string'
fullWidth
placeholder='Email Address'
name='email'
onChange={(e) => setEmail(e.target.value)}
value={email}
/>
</Box>
</Box>
</SettingsSection>
{!currentUser.isSSO && (
<SettingsSection
action={
<StyledButton
disabled={!newPassword || !confirmPassword || newPassword !== confirmPassword}
onClick={savePassword}
sx={{ borderRadius: 2, height: 40 }}
variant='contained'
>
Save
</StyledButton>
}
title='Security'
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: gridSpacing,
px: 2.5,
py: 2
}}
>
<Box
sx={{
gridColumn: 'span 2 / span 2',
display: 'flex',
flexDirection: 'column',
gap: 1
}}
>
<Typography variant='body1'>New Password</Typography>
<OutlinedInput
id='newPassword'
type='password'
fullWidth
placeholder='New Password'
name='newPassword'
onChange={(e) => setNewPassword(e.target.value)}
value={newPassword}
/>
<Typography variant='caption'>
<i>
Password must be at least 8 characters long and contain at least one lowercase letter,
one uppercase letter, one digit, and one special character.
</i>
</Typography>
</Box>
<Box
sx={{
gridColumn: 'span 2 / span 2',
display: 'flex',
flexDirection: 'column',
gap: 1
}}
>
<Typography variant='body1'>Confirm Password</Typography>
<OutlinedInput
id='confirmPassword'
type='password'
fullWidth
placeholder='Confirm Password'
name='confirmPassword'
onChange={(e) => setConfirmPassword(e.target.value)}
value={confirmPassword}
/>
</Box>
</Box>
</SettingsSection>
)}
</>
)}
</Stack>
)}
{openPricingDialog && isCloud && (
<PricingDialog
open={openPricingDialog}
onClose={(planUpdated) => {
setOpenPricingDialog(false)
if (planUpdated) {
navigate('/')
navigate(0)
}
}}
/>
)}
{/* Remove Seats Dialog */}
<Dialog fullWidth maxWidth='sm' open={openRemoveSeatsDialog} onClose={handleRemoveSeatsDialogClose}>
<DialogTitle variant='h4'>Remove Additional Seats</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
{emptySeats === 0 ? (
<Typography
color='error'
sx={{
p: 2,
borderRadius: 1,
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<IconAlertCircle size={20} />
You must remove users from your organization before removing seats.
</Typography>
) : (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
backgroundColor: theme.palette.background.paper,
borderRadius: 1,
p: 2
}}
>
{/* Occupied Seats */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Occupied Seats</Typography>
<Typography variant='body2'>{occupiedSeats}</Typography>
</Box>
{/* Empty Seats */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Empty Seats</Typography>
<Typography variant='body2'>{emptySeats}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Number of Empty Seats to Remove</Typography>
<TextField
size='small'
type='number'
value={seatsQuantity}
onChange={(e) => {
const value = Math.max(0, Math.min(emptySeats, parseInt(e.target.value) || 0))
handleQuantityChange(value, 'remove')
}}
onKeyDown={(e) => {
if (e.key === '-' || e.key === 'e') {
e.preventDefault()
}
}}
InputProps={{
inputProps: {
min: 0,
max: emptySeats,
step: 1
}
}}
sx={{ width: '70px' }}
disabled={!getCustomerDefaultSourceApi.data}
/>
</Box>
{/* Total Seats */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pt: 1.5,
borderTop: `1px solid ${theme.palette.divider}`
}}
>
<Typography variant='h5'>New Total Seats</Typography>
<Typography variant='h5'>{totalSeats - seatsQuantity}</Typography>
</Box>
</Box>
)}
{getAdditionalSeatsProrationApi.loading && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={16} />
</Box>
)}
{getCustomerDefaultSourceApi.loading ? (
<CircularProgress size={20} />
) : getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 2 }}>
<Typography variant='subtitle2'>Payment Method</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card && (
<>
<IconCreditCard size={20} stroke={1.5} color={theme.palette.primary.main} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ textTransform: 'capitalize' }}>
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.brand}
</Typography>
<Typography>
{' '}
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.last4}
</Typography>
<Typography color='text.secondary'>
(expires{' '}
{
getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card
.exp_month
}
/
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.exp_year}
)
</Typography>
</Box>
</>
)}
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
<Typography color='error' sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconAlertCircle size={20} />
No payment method found
</Typography>
<Button
variant='contained'
endIcon={<IconExternalLink />}
onClick={() => {
setOpenRemoveSeatsDialog(false)
handleBillingPortalClick()
}}
>
Add Payment Method in Billing Portal
</Button>
</Box>
)}
{/* Proration info */}
{prorationInfo && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
backgroundColor: theme.palette.background.paper,
borderRadius: 1,
p: 2
}}
>
{/* Date Range */}
<Typography variant='body2' color='text.secondary'>
{new Date(prorationInfo.currentPeriodStart * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})}{' '}
-{' '}
{new Date(prorationInfo.currentPeriodEnd * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Typography>
{/* Base Plan */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Typography variant='body2'>{currentPlanTitle}</Typography>
<Typography variant='body2'>
{prorationInfo.currency} {Math.max(0, prorationInfo.basePlanAmount).toFixed(2)}
</Typography>
</Box>
{/* Additional Seats */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Box>
<Typography variant='body2'>Additional Seats Left (Prorated)</Typography>
<Typography variant='caption' color='text.secondary'>
Qty {purchasedSeats - seatsQuantity}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant='body2'>
{prorationInfo.currency} {Math.max(0, prorationInfo.additionalSeatsProratedAmount).toFixed(2)}
</Typography>
<Typography variant='caption' color='text.secondary'>
{prorationInfo.currency} {prorationInfo.seatPerUnitPrice.toFixed(2)} each
</Typography>
</Box>
</Box>
{prorationInfo.prorationAmount < 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Typography variant='body2'>Credit balance</Typography>
<Typography
variant='body2'
color={prorationInfo.prorationAmount < 0 ? 'success.main' : 'error.main'}
>
{prorationInfo.currency} {prorationInfo.prorationAmount < 0 ? '+' : ''}
{Math.abs(prorationInfo.prorationAmount).toFixed(2)}
</Typography>
</Box>
)}
{/* Next Payment */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pt: 1.5,
borderTop: `1px solid ${theme.palette.divider}`
}}
>
<Typography variant='h5'>Due today</Typography>
<Typography variant='h5'>
{prorationInfo.currency} {Math.max(0, prorationInfo.prorationAmount).toFixed(2)}
</Typography>
</Box>
{prorationInfo.prorationAmount < 0 && (
<Typography
variant='body2'
sx={{
color: 'info.main',
fontStyle: 'italic'
}}
>
Your available credit will automatically apply to your next invoice.
</Typography>
)}
</Box>
)}
</Box>
</DialogContent>
{getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method && (
<DialogActions>
<Button onClick={handleRemoveSeatsDialogClose} disabled={isUpdatingSeats}>
Cancel
</Button>
<Button
variant='outlined'
onClick={() => handleSeatsModification(purchasedSeats - seatsQuantity)}
disabled={
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data ||
getAdditionalSeatsProrationApi.loading ||
isUpdatingSeats ||
seatsQuantity === 0 ||
emptySeats === 0
}
color='error'
>
{isUpdatingSeats ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color='inherit' />
Updating...
</Box>
) : (
'Remove Seats'
)}
</Button>
</DialogActions>
)}
</Dialog>
{/* Add Seats Dialog */}
<Dialog fullWidth maxWidth='sm' open={openAddSeatsDialog} onClose={handleAddSeatsDialogClose}>
<DialogTitle variant='h4'>Add Additional Seats</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
backgroundColor: theme.palette.background.paper,
borderRadius: 1,
p: 2
}}
>
{/* Occupied Seats */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Occupied Seats</Typography>
<Typography variant='body2'>{occupiedSeats}</Typography>
</Box>
{/* Included Seats */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Seats Included with Plan</Typography>
<Typography variant='body2'>{includedSeats}</Typography>
</Box>
{/* Additional Seats */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Additional Seats Purchased</Typography>
<Typography variant='body2'>{purchasedSeats}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Number of Additional Seats to Add</Typography>
<TextField
size='small'
type='number'
value={seatsQuantity}
onChange={(e) => {
const value = Math.max(0, parseInt(e.target.value) || 0)
handleQuantityChange(value, 'add')
}}
onKeyDown={(e) => {
if (e.key === '-' || e.key === 'e') {
e.preventDefault()
}
}}
InputProps={{
inputProps: {
min: 0
}
}}
sx={{ width: '70px' }}
disabled={!getCustomerDefaultSourceApi.data}
/>
</Box>
{/* Total Seats */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pt: 1.5,
borderTop: `1px solid ${theme.palette.divider}`
}}
>
<Typography variant='h5'>New Total Seats</Typography>
<Typography variant='h5'>{totalSeats + seatsQuantity}</Typography>
</Box>
</Box>
{getAdditionalSeatsProrationApi.loading && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={16} />
</Box>
)}
{getCustomerDefaultSourceApi.loading ? (
<CircularProgress size={20} />
) : getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 2 }}>
<Typography variant='subtitle2'>Payment Method</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card && (
<>
<IconCreditCard size={20} stroke={1.5} color={theme.palette.primary.main} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ textTransform: 'capitalize' }}>
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.brand}
</Typography>
<Typography>
{' '}
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.last4}
</Typography>
<Typography color='text.secondary'>
(expires{' '}
{
getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card
.exp_month
}
/
{getCustomerDefaultSourceApi.data.invoice_settings.default_payment_method.card.exp_year}
)
</Typography>
</Box>
</>
)}
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
<Typography color='error' sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconAlertCircle size={20} />
No payment method found
</Typography>
<Button
variant='contained'
endIcon={<IconExternalLink />}
onClick={() => {
setOpenRemoveSeatsDialog(false)
handleBillingPortalClick()
}}
>
Add Payment Method in Billing Portal
</Button>
</Box>
)}
{/* Proration info */}
{prorationInfo && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
backgroundColor: theme.palette.background.paper,
borderRadius: 1,
p: 2
}}
>
{/* Date Range */}
<Typography variant='body2' color='text.secondary'>
{new Date(prorationInfo.currentPeriodStart * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})}{' '}
-{' '}
{new Date(prorationInfo.currentPeriodEnd * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Typography>
{/* Base Plan */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>{currentPlanTitle}</Typography>
<Typography variant='body2'>
{prorationInfo.currency} {prorationInfo.basePlanAmount.toFixed(2)}
</Typography>
</Box>
{/* Additional Seats */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant='body2'>Additional Seats (Prorated)</Typography>
<Typography variant='caption' color='text.secondary'>
Qty {seatsQuantity + purchasedSeats}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant='body2'>
{prorationInfo.currency} {prorationInfo.additionalSeatsProratedAmount.toFixed(2)}
</Typography>
<Typography variant='caption' color='text.secondary'>
{prorationInfo.currency} {prorationInfo.seatPerUnitPrice.toFixed(2)} each
</Typography>
</Box>
</Box>
{/* Credit Balance */}
{prorationInfo.creditBalance !== 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant='body2'>Applied account balance</Typography>
<Typography variant='body2' color={prorationInfo.creditBalance < 0 ? 'success.main' : 'error.main'}>
{prorationInfo.currency} {prorationInfo.creditBalance.toFixed(2)}
</Typography>
</Box>
)}
{/* Next Payment */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pt: 1.5,
borderTop: `1px solid ${theme.palette.divider}`
}}
>
<Typography variant='h5'>Due today</Typography>
<Typography variant='h5'>
{prorationInfo.currency}{' '}
{Math.max(0, prorationInfo.prorationAmount + prorationInfo.creditBalance).toFixed(2)}
</Typography>
</Box>
{prorationInfo.prorationAmount === 0 && prorationInfo.creditBalance < 0 && (
<Typography
variant='body2'
sx={{
color: 'info.main',
fontStyle: 'italic'
}}
>
Your available credit will automatically apply to your next invoice.
</Typography>
)}
</Box>
)}
</Box>
</DialogContent>
{getCustomerDefaultSourceApi.data?.invoice_settings?.default_payment_method && (
<DialogActions>
<Button onClick={handleAddSeatsDialogClose} disabled={isUpdatingSeats}>
Cancel
</Button>
<Button
variant='contained'
onClick={() => handleSeatsModification(seatsQuantity + purchasedSeats)}
disabled={
getCustomerDefaultSourceApi.loading ||
!getCustomerDefaultSourceApi.data ||
getAdditionalSeatsProrationApi.loading ||
isUpdatingSeats ||
seatsQuantity === 0
}
>
{isUpdatingSeats ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color='inherit' />
Updating...
</Box>
) : (
'Add Seats'
)}
</Button>
</DialogActions>
)}
</Dialog>
</MainCard>
)
}
export default AccountSettings