This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -0,0 +1,95 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { fetchStorageInfo } from '@/features/admin-center/admin-center.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { SUBSCRIPTION_STATUS } from '@/shared/constants';
import { IBillingAccountStorage } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { Card, Progress, Typography } from 'antd/es';
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface IAccountStorageProps {
themeMode: string;
}
const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
const { t } = useTranslation('admin-center/current-bill');
const dispatch = useAppDispatch();
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(state => state.adminCenterReducer);
const formatBytes = useMemo(
() =>
(bytes = 0, decimals = 2) => {
if (!+bytes) return '0 MB';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const formattedValue = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${formattedValue} ${subscriptionType !== SUBSCRIPTION_STATUS.FREE ? sizes[i] : 'MB'}`;
},
[subscriptionType]
);
useEffect(() => {
dispatch(fetchStorageInfo());
}, []);
useEffect(() => {
setSubscriptionType(billingInfo?.status ?? SUBSCRIPTION_STATUS.TRIALING);
}, [billingInfo?.status]);
const textColor = themeMode === 'dark' ? '#ffffffd9' : '#000000d9';
return (
<Card
style={{
height: '100%',
}}
loading={loadingBillingInfo}
title={
<span
style={{
color: textColor,
fontWeight: 500,
fontSize: '16px',
}}
>
{t('accountStorage')}
</span>
}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ padding: '0 16px' }}>
<Progress
percent={billingInfo?.usedPercentage ?? 0}
type="circle"
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<Typography.Text>
{t('used')} <strong>{formatBytes(storageInfo?.used ?? 0, 1)}</strong>
</Typography.Text>
<Typography.Text>
{t('remaining')} <strong>{formatBytes(storageInfo?.remaining ?? 0, 1)}</strong>
</Typography.Text>
</div>
</div>
</Card>
);
};
export default AccountStorage;

View File

@@ -0,0 +1,99 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingCharge, IBillingChargesResponse } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { formatDate } from '@/utils/timeUtils';
import { Table, TableProps, Tag } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const ChargesTable: React.FC = () => {
const { t } = useTranslation('admin-center/current-bill');
const [charges, setCharges] = useState<IBillingChargesResponse>({});
const [loadingCharges, setLoadingCharges] = useState(false);
const fetchCharges = async () => {
try {
setLoadingCharges(true);
const res = await adminCenterApiService.getCharges();
if (res.done) {
setCharges(res.body);
}
} catch (error) {
logger.error('Error fetching charges:', error);
} finally {
setLoadingCharges(false);
}
};
const columns: TableProps<IBillingCharge>['columns'] = [
{
title: t('description'),
key: 'name',
dataIndex: 'name',
},
{
title: t('billingPeriod'),
key: 'billingPeriod',
render: record => {
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
},
},
{
title: t('billStatus'),
key: 'status',
dataIndex: 'status',
render: (_, record) => {
return (
<Tag
color={
record.status === 'success' ? 'green' : record.status === 'deleted' ? 'red' : 'blue'
}
>
{record.status?.toUpperCase()}
</Tag>
);
},
},
{
title: t('perUserValue'),
key: 'perUserValue',
dataIndex: 'perUserValue',
render: (_, record) => (
<span>
{record.currency} {record.unit_price}
</span>
),
},
{
title: t('users'),
key: 'quantity',
dataIndex: 'quantity',
},
{
title: t('amount'),
key: 'amount',
dataIndex: 'amount',
render: (_, record) => (
<span>
{record.currency} {record.amount}
</span>
),
},
];
useEffect(() => {
fetchCharges();
}, []);
return (
<Table<IBillingCharge>
columns={columns}
dataSource={charges.plan_charges}
pagination={false}
loading={loadingCharges}
rowKey="id"
/>
);
};
export default ChargesTable;

View File

@@ -0,0 +1,105 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingTransaction } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { formatDate } from '@/utils/timeUtils';
import { ContainerOutlined } from '@ant-design/icons';
import { Button, Table, TableProps, Tag, Tooltip } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const InvoicesTable: React.FC = () => {
const { t } = useTranslation('admin-center/current-bill');
const [transactions, setTransactions] = useState<IBillingTransaction[]>([]);
const [loadingTransactions, setLoadingTransactions] = useState(false);
const fetchTransactions = async () => {
try {
setLoadingTransactions(true);
const res = await adminCenterApiService.getTransactions();
if (res.done) {
setTransactions(res.body);
}
} catch (error) {
logger.error('Error fetching transactions:', error);
} finally {
setLoadingTransactions(false);
}
};
const handleInvoiceViewClick = (record: IBillingTransaction) => {
if (!record.receipt_url) return;
window.open(record.receipt_url, '_blank');
};
useEffect(() => {
fetchTransactions();
}, []);
const columns: TableProps<IBillingTransaction>['columns'] = [
{
title: t('transactionId'),
key: 'transactionId',
dataIndex: 'subscription_payment_id',
},
{
title: t('transactionDate'),
key: 'transactionDate',
render: record => `${formatDate(new Date(record.event_time))}`,
},
{
title: t('billingPeriod'),
key: 'billingPeriod',
render: record => {
return `${formatDate(new Date(record.event_time))} - ${formatDate(new Date(record.next_bill_date))}`;
},
},
{
title: t('paymentMethod'),
key: 'paymentMethod',
dataIndex: 'payment_method',
},
{
title: t('status'),
key: 'status',
dataIndex: 'status',
render: (_, record) => (
<Tag
color={
record.payment_status === 'success'
? 'green'
: record.payment_status === 'failed'
? 'red'
: 'blue'
}
>
{record.payment_status?.toUpperCase()}
</Tag>
),
},
{
key: 'button',
render: (_, record) => (
<Tooltip title={t('viewInvoice')}>
<Button size="small">
<ContainerOutlined onClick={() => handleInvoiceViewClick(record)} />
</Button>
</Tooltip>
),
},
];
return (
<Table
columns={columns}
dataSource={transactions}
pagination={false}
loading={loadingTransactions}
rowKey="id"
/>
);
};
export default InvoicesTable;

View File

@@ -0,0 +1,12 @@
.current-billing .ant-card-head-wrapper {
padding: 16px 0;
}
.current-billing .ant-progress-inner {
width: 75px !important;
height: 75px !important;
}
:where(.css-dev-only-do-not-override-ezht69).ant-modal .ant-modal-footer {
margin-top: 0 !important;
}

View File

@@ -0,0 +1,141 @@
import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd';
import React, { useEffect } from 'react';
import './current-bill.css';
import { InfoCircleTwoTone } from '@ant-design/icons';
import ChargesTable from './billing-tables/charges-table';
import InvoicesTable from './billing-tables/invoices-table';
import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr';
import UpgradePlans from './drawers/upgrade-plans/upgrade-plans';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMediaQuery } from 'react-responsive';
import { useTranslation } from 'react-i18next';
import {
toggleDrawer,
toggleUpgradeModal,
} from '@/features/admin-center/billing/billing.slice';
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer';
import CurrentPlanDetails from './current-plan-details/current-plan-details';
import AccountStorage from './account-storage/account-storage';
import { useAuthService } from '@/hooks/useAuth';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
const CurrentBill: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const currentSession = useAuthService().getCurrentSession();
useEffect(() => {
dispatch(fetchBillingInfo());
dispatch(fetchFreePlanSettings());
}, [dispatch]);
const titleStyle = {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
};
const renderMobileView = () => (
<div>
<Col span={24}>
<Card
style={{ height: '100%' }}
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
extra={
<div style={{ marginTop: '8px', marginRight: '8px' }}>
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
{t('upgradePlan')}
</Button>
<Modal
open={isUpgradeModalOpen}
onCancel={() => dispatch(toggleUpgradeModal())}
width={1000}
centered
okButtonProps={{ hidden: true }}
cancelButtonProps={{ hidden: true }}
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
<div style={{ marginBottom: '14px' }}>
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
</div>
<Button
type="link"
style={{ margin: 0, padding: 0, width: '90px' }}
onClick={() => dispatch(toggleDrawer())}
>
{t('redeemCode')}
</Button>
<RedeemCodeDrawer />
</div>
</Card>
</Col>
<Col span={24} style={{ marginTop: '1.5rem' }}>
<AccountStorage themeMode={themeMode} />
</Col>
</div>
);
const renderChargesAndInvoices = () => (
<>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={
<span style={titleStyle}>
<span>{t('charges')}</span>
<Tooltip title={t('tooltip')}>
<InfoCircleTwoTone />
</Tooltip>
</span>
}
style={{ marginTop: '16px' }}
>
<ChargesTable />
</Card>
</div>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={<span style={titleStyle}>{t('invoices')}</span>}
style={{ marginTop: '16px' }}
>
<InvoicesTable />
</Card>
</div>
</>
);
return (
<div style={{ width: '100%' }} className="current-billing">
{isTablet ? (
<Row>
<Col span={16} style={{ paddingRight: '10px' }}>
<CurrentPlanDetails />
</Col>
<Col span={8} style={{ paddingLeft: '10px' }}>
<AccountStorage themeMode={themeMode} />
</Col>
</Row>
) : (
renderMobileView()
)}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderChargesAndInvoices()}
</div>
);
};
export default CurrentBill;

View File

@@ -0,0 +1,437 @@
import React, { useState } from 'react';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
evt_billing_pause_plan,
evt_billing_resume_plan,
evt_billing_add_more_seats,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import logger from '@/utils/errorLogger';
import { Button, Card, Flex, Modal, Space, Tooltip, Typography, Statistic, Select, Form, Row, Col } from 'antd/es';
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
import {
fetchBillingInfo,
toggleRedeemCodeDrawer,
toggleUpgradeModal,
} from '@/features/admin-center/admin-center.slice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { WarningTwoTone, PlusOutlined } from '@ant-design/icons';
import { calculateTimeGap } from '@/utils/calculate-time-gap';
import { formatDate } from '@/utils/timeUtils';
import UpgradePlansLKR from '../drawers/upgrade-plans-lkr/upgrade-plans-lkr';
import UpgradePlans from '../drawers/upgrade-plans/upgrade-plans';
import { ISUBSCRIPTION_TYPE, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { billingApiService } from '@/api/admin-center/billing.api.service';
const CurrentPlanDetails = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const { trackMixpanelEvent } = useMixpanelTracking();
const [pausingPlan, setPausingPlan] = useState(false);
const [cancellingPlan, setCancellingPlan] = useState(false);
const [addingSeats, setAddingSeats] = useState(false);
const [isMoreSeatsModalVisible, setIsMoreSeatsModalVisible] = useState(false);
const [selectedSeatCount, setSelectedSeatCount] = useState<number | string>(5);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
state => state.adminCenterReducer
);
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
type SeatOption = { label: string; value: number | string };
const seatCountOptions: SeatOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]
.map(value => ({ label: value.toString(), value }));
seatCountOptions.push({ label: '100+', value: '100+' });
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
const isResume = action === 'resume';
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
const apiMethod = isResume
? adminCenterApiService.resumeSubscription
: adminCenterApiService.pauseSubscription;
const eventType = isResume ? evt_billing_resume_plan : evt_billing_pause_plan;
try {
setLoadingState(true);
const res = await apiMethod();
if (res.done) {
setTimeout(() => {
setLoadingState(false);
dispatch(fetchBillingInfo());
trackMixpanelEvent(eventType);
}, 8000);
return; // Exit function to prevent finally block from executing
}
} catch (error) {
logger.error(`Error ${action}ing subscription`, error);
setLoadingState(false); // Only set to false on error
}
};
const handleAddMoreSeats = () => {
setIsMoreSeatsModalVisible(true);
};
const handlePurchaseMoreSeats = async () => {
if (selectedSeatCount.toString() === '100+' || !billingInfo?.total_seats) return;
try {
setAddingSeats(true);
const totalSeats = Number(selectedSeatCount) + (billingInfo?.total_seats || 0);
const res = await billingApiService.purchaseMoreSeats(totalSeats);
if (res.done) {
setIsMoreSeatsModalVisible(false);
dispatch(fetchBillingInfo());
trackMixpanelEvent(evt_billing_add_more_seats);
}
} catch (error) {
logger.error('Error adding more seats', error);
} finally {
setAddingSeats(false);
}
};
const calculateRemainingSeats = () => {
if (billingInfo?.total_seats && billingInfo?.total_used) {
return billingInfo.total_seats - billingInfo.total_used;
}
return 0;
};
const checkSubscriptionStatus = (allowedStatuses: any[]) => {
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
return allowedStatuses.includes(billingInfo.status);
};
const shouldShowRedeemButton = () => {
if (billingInfo?.trial_in_progress) return true;
return billingInfo?.ltd_users ? billingInfo.ltd_users < 50 : false;
};
const showChangeButton = () => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
};
const showPausePlanButton = () => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
};
const showResumePlanButton = () => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
};
const shouldShowAddSeats = () => {
if (!billingInfo) return false;
return billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE;
};
const renderExtra = () => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
<Space>
{showPausePlanButton() && (
<Button
type="link"
danger
loading={pausingPlan}
onClick={() => handleSubscriptionAction('pause')}
>
{t('pausePlan')}
</Button>
)}
{showResumePlanButton() && (
<Button
type="primary"
loading={cancellingPlan}
onClick={() => handleSubscriptionAction('resume')}
>
{t('resumePlan')}
</Button>
)}
{billingInfo.trial_in_progress && (
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
{t('upgradePlan')}
</Button>
)}
{showChangeButton() && (
<Button
type="primary"
loading={pausingPlan || cancellingPlan}
onClick={() => dispatch(toggleUpgradeModal())}
>
{t('changePlan')}
</Button>
)}
</Space>
);
};
const renderLtdDetails = () => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
<Flex vertical>
<Typography.Text strong>{billingInfo.plan_name}</Typography.Text>
<Typography.Text>{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}</Typography.Text>
</Flex>
);
};
const renderTrialDetails = () => {
const checkIfTrialExpired = () => {
if (!billingInfo?.trial_expire_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const trialExpireDate = new Date(billingInfo.trial_expire_date);
trialExpireDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
return today > trialExpireDate;
};
const getExpirationMessage = (expireDate: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const expDate = new Date(expireDate);
expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
if (expDate.getTime() === today.getTime()) {
return t('expirestoday', 'today');
} else if (expDate.getTime() === tomorrow.getTime()) {
return t('expirestomorrow', 'tomorrow');
} else if (expDate < today) {
const diffTime = Math.abs(today.getTime() - expDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
} else {
return calculateTimeGap(expireDate);
}
};
const isExpired = checkIfTrialExpired();
const trialExpireDate = billingInfo?.trial_expire_date || '';
return (
<Flex vertical>
<Typography.Text strong>
{t('trialPlan')}
{isExpired && <WarningTwoTone twoToneColor="#faad14" style={{ marginLeft: 8 }} />}
</Typography.Text>
<Tooltip title={formatDate(new Date(trialExpireDate))}>
<Typography.Text>
{isExpired
? t('trialExpired', {
trial_expire_string: getExpirationMessage(trialExpireDate)
})
: t('trialInProgress', {
trial_expire_string: getExpirationMessage(trialExpireDate)
})
}
</Typography.Text>
</Tooltip>
</Flex>
);
};
const renderFreePlan = () => (
<Flex vertical>
<Typography.Text strong>Free Plan</Typography.Text>
<Typography.Text>
<br />-{' '}
{freePlanSettings?.team_member_limit === 0
? t('unlimitedTeamMembers')
: `${freePlanSettings?.team_member_limit} ${t('teamMembers')}`}
<br />- {freePlanSettings?.projects_limit} {t('projects')}
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
</Typography.Text>
</Flex>
);
const renderPaddleSubscriptionInfo = () => {
return (
<Flex vertical>
<Typography.Text strong>{billingInfo?.plan_name}</Typography.Text>
<Flex>
<Typography.Text>{billingInfo?.default_currency}</Typography.Text>&nbsp;
<Typography.Text>
{billingInfo?.billing_type === 'year'
? billingInfo.unit_price_per_month
: billingInfo?.unit_price}
&nbsp;{t('perMonthPerUser')}
</Typography.Text>
</Flex>
{shouldShowAddSeats() && billingInfo?.total_seats && (
<div style={{ marginTop: '16px' }}>
<Row gutter={16} align="middle">
<Col span={6}>
<Statistic
title={t('totalSeats')}
value={billingInfo.total_seats}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddMoreSeats}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
{t('addMoreSeats')}
</Button>
</Col>
<Col span={6}>
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
</Row>
</div>
)}
</Flex>
);
};
const renderCreditSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>Credit Plan</Typography.Text>
</Flex>
};
const renderCustomSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>Custom Plan</Typography.Text>
<Typography.Text>Your plan is valid till {billingInfo?.valid_till_date}</Typography.Text>
</Flex>
};
return (
<Card
style={{ height: '100%' }}
title={
<Typography.Text
style={{
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
}}
>
{t('currentPlanDetails')}
</Typography.Text>
}
loading={loadingBillingInfo}
extra={renderExtra()}
>
<Flex vertical>
<div style={{ marginBottom: '14px' }}>
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL && renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT && renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM && renderCustomSubscriptionInfo()}
</div>
{shouldShowRedeemButton() && (
<>
<Button
type="link"
style={{ margin: 0, padding: 0, width: '90px' }}
onClick={() => dispatch(toggleRedeemCodeDrawer())}
>
{t('redeemCode')}
</Button>
<RedeemCodeDrawer />
</>
)}
<Modal
open={isUpgradeModalOpen}
onCancel={() => dispatch(toggleUpgradeModal())}
width={1000}
centered
okButtonProps={{ hidden: true }}
cancelButtonProps={{ hidden: true }}
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
<Modal
title={t('addMoreSeats')}
open={isMoreSeatsModalVisible}
onCancel={() => setIsMoreSeatsModalVisible(false)}
footer={null}
width={500}
centered
>
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
To continue, you'll need to purchase additional seats.
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
You currently have {billingInfo?.total_seats} seats available.
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
Please select the number of additional seats to purchase.
</Typography.Paragraph>
<div style={{ marginBottom: '24px' }}>
<span style={{ color: '#ff4d4f', marginRight: '4px' }}>*</span>
<span style={{ marginRight: '8px' }}>Seats:</span>
<Select
value={selectedSeatCount}
onChange={setSelectedSeatCount}
options={seatCountOptions}
style={{ width: '300px' }}
/>
</div>
<Flex justify="end">
{selectedSeatCount.toString() !== '100+' ? (
<Button
type="primary"
loading={addingSeats}
onClick={handlePurchaseMoreSeats}
style={{
minWidth: '100px',
backgroundColor: '#1890ff',
borderColor: '#1890ff',
borderRadius: '2px'
}}
>
Purchase
</Button>
) : (
<Button
type="primary"
size="middle"
>
Contact sales
</Button>
)}
</Flex>
</Flex>
</Modal>
</Flex>
</Card>
);
};
export default CurrentPlanDetails;

View File

@@ -0,0 +1,103 @@
import { Button, Drawer, Form, Input, notification, Typography } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
fetchBillingInfo,
fetchStorageInfo,
toggleRedeemCodeDrawer,
} from '@features/admin-center/admin-center.slice';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
import { setSession } from '@/utils/session-helper';
const RedeemCodeDrawer: React.FC = () => {
const [form] = Form.useForm();
const { t } = useTranslation('admin-center/current-bill');
const { isRedeemCodeDrawerOpen } = useAppSelector(state => state.adminCenterReducer);
const dispatch = useAppDispatch();
const [redeemCode, setRedeemCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const handleFormSubmit = async (values: any) => {
if (!values.redeemCode) return;
try {
setIsLoading(true);
const res = await adminCenterApiService.redeemCode(values.redeemCode);
if (res.done) {
form.resetFields();
const authorizeResponse = await authApiService.verify();
if (authorizeResponse.authenticated) {
setSession(authorizeResponse.user);
dispatch(setUser(authorizeResponse.user));
}
dispatch(toggleRedeemCodeDrawer());
dispatch(fetchBillingInfo());
dispatch(fetchStorageInfo());
}
} catch (error) {
logger.error('Error redeeming code', error);
} finally {
setIsLoading(false);
}
};
return (
<div>
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('drawerTitle')}
</Typography.Text>
}
open={isRedeemCodeDrawerOpen}
onClose={() => {
dispatch(toggleRedeemCodeDrawer());
form.resetFields();
}}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="redeemCode"
label={t('label')}
rules={[
{
required: true,
message: t('required'),
},
{
pattern: /^[A-Za-z0-9]+$/,
message: t('invalidCode'),
},
]}
>
<Input
placeholder={t('drawerPlaceholder')}
onChange={e => setRedeemCode(e.target.value.toUpperCase())}
count={{ show: true, max: 10 }}
value={redeemCode}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
style={{ width: '100%' }}
htmlType="submit"
disabled={redeemCode.length !== 10}
loading={isLoading}
>
{t('redeemSubmit')}
</Button>
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default RedeemCodeDrawer;

View File

@@ -0,0 +1,3 @@
.upgrade-plans .ant-card-head-wrapper {
padding: 16px 0;
}

View File

@@ -0,0 +1,235 @@
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from 'antd';
import React, { useState } from 'react';
import './upgrade-plans-lkr.css';
import { CheckCircleFilled } from '@ant-design/icons';
import { RootState } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { timeZoneCurrencyMap } from '@/utils/timeZoneCurrencyMap';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleUpgradeModal, fetchBillingInfo } from '@features/admin-center/admin-center.slice';
import { useAuthService } from '@/hooks/useAuth';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { setSession } from '@/utils/session-helper';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
const UpgradePlansLKR: React.FC = () => {
const dispatch = useAppDispatch();
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [selectedPlan, setSelectedPlan] = useState(2);
const { t } = useTranslation('admin-center/current-bill');
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const userCurrency = timeZoneCurrencyMap[userTimeZone] || 'USD';
const [switchingToFreePlan, setSwitchingToFreePlan] = useState(false);
const currentSession = useAuthService().getCurrentSession();
const cardStyles = {
title: {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
justifyContent: 'center',
},
priceContainer: {
display: 'grid',
gridTemplateColumns: 'auto',
rowGap: '10px',
padding: '20px 30px 0',
},
featureList: {
display: 'grid',
gridTemplateRows: 'auto auto auto',
gridTemplateColumns: '200px',
rowGap: '7px',
padding: '10px',
justifyItems: 'start',
alignItems: 'start',
},
checkIcon: { color: '#52c41a' },
};
const handlePlanSelect = (planIndex: number) => {
setSelectedPlan(planIndex);
};
const handleSeatsChange = (values: { seats: number }) => {
if (values.seats <= 15) {
setSelectedPlan(2);
} else if (values.seats > 15 && values.seats <= 200) {
setSelectedPlan(3);
} else if (values.seats > 200) {
setSelectedPlan(4);
}
};
const isSelected = (planIndex: number) =>
selectedPlan === planIndex ? { border: '2px solid #1890ff' } : {};
const handleSubmit = () => {
notification.success({
message: t('submitSuccess'),
description: t('submitSuccessDescription'),
placement: 'topRight',
});
dispatch(toggleUpgradeModal());
};
const renderFeature = (text: string) => (
<div>
<CheckCircleFilled style={cardStyles.checkIcon} />
&nbsp;
<span>{text}</span>
</div>
);
const renderPlanCard = (
planIndex: number,
title: string,
price: string | number,
subtitle: string,
users: string,
features: string[],
tag?: string
) => (
<Col span={6} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(planIndex), height: '100%' }}
hoverable
title={
<span style={cardStyles.title}>
{title}
{tag && <Tag color="volcano">{tag}</Tag>}
</span>
}
onClick={() => handlePlanSelect(planIndex)}
>
<div style={cardStyles.priceContainer}>
<Typography.Title level={1}>
{userCurrency} {price}
</Typography.Title>
<span>{subtitle}</span>
<Typography.Title level={5}>{users}</Typography.Title>
</div>
<div style={cardStyles.featureList}>
{features.map((feature, index) => renderFeature(t(feature)))}
</div>
</Card>
</Col>
);
const switchToFreePlan = async () => {
const teamId = currentSession?.team_id;
if (!teamId) return;
try {
setSwitchingToFreePlan(true);
const res = await adminCenterApiService.switchToFreePlan(teamId);
if (res.done) {
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
const authorizeResponse = await authApiService.verify();
if (authorizeResponse.authenticated) {
setSession(authorizeResponse.user);
dispatch(setUser(authorizeResponse.user));
window.location.href = '/worklenz/admin-center/billing';
}
}
} catch (error) {
logger.error('Error switching to free plan', error);
} finally {
setSwitchingToFreePlan(false);
}
};
return (
<div className="upgrade-plans" style={{ marginTop: '1.5rem', textAlign: 'center' }}>
<Typography.Title level={2}>{t('modalTitle')}</Typography.Title>
{selectedPlan !== 1 && (
<Row justify="center">
<Form initialValues={{ seats: 15 }} onValuesChange={handleSeatsChange}>
<Form.Item name="seats" label={t('seatLabel')}>
<Input type="number" min={15} step={5} />
</Form.Item>
</Form>
</Row>
)}
<Row>
{renderPlanCard(1, t('freePlan'), 0.0, t('freeSubtitle'), t('freeUsers'), [
'freeText01',
'freeText02',
'freeText03',
])}
{renderPlanCard(2, t('startup'), 4990, t('startupSubtitle'), t('startupUsers'), [
'startupText01',
'startupText02',
'startupText03',
'startupText04',
'startupText05',
])}
{renderPlanCard(
3,
t('business'),
300,
t('businessSubtitle'),
'16 - 200 users',
['startupText01', 'startupText02', 'startupText03', 'startupText04', 'startupText05'],
t('tag')
)}
{renderPlanCard(4, t('enterprise'), 250, t('businessSubtitle'), t('enterpriseUsers'), [
'startupText01',
'startupText02',
'startupText03',
'startupText04',
'startupText05',
])}
</Row>
{selectedPlan === 1 ? (
<Row justify="center" style={{ marginTop: '1.5rem' }}>
<Button type="primary" loading={switchingToFreePlan} onClick={switchToFreePlan}>
{t('switchToFreePlan')}
</Button>
</Row>
) : (
<div
style={{
backgroundColor: themeMode === 'dark' ? '#141414' : '#e2e3e5',
padding: '1rem',
marginTop: '1.5rem',
}}
>
<Typography.Title level={4}>{t('footerTitle')}</Typography.Title>
<Form onFinish={handleSubmit}>
<Row justify="center" style={{ height: '32px' }}>
<Form.Item
style={{ margin: '0 24px 0 0' }}
name="contactNumber"
label={t('footerLabel')}
rules={[{ required: true }]}
>
<Input type="number" placeholder="07xxxxxxxx" maxLength={10} minLength={10} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
{t('footerButton')}
</Button>
</Form.Item>
</Row>
</Form>
</div>
)}
</div>
);
};
export default UpgradePlansLKR;

View File

@@ -0,0 +1,523 @@
import { useEffect, useState } from 'react';
import { Button, Card, Col, Flex, Form, Row, Select, Tag, Tooltip, Typography, message } from 'antd/es';
import { useTranslation } from 'react-i18next';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
IPricingPlans,
IUpgradeSubscriptionPlanResponse,
} from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IPaddlePlans, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { CheckCircleFilled, InfoCircleOutlined } from '@ant-design/icons';
import { useAuthService } from '@/hooks/useAuth';
import { fetchBillingInfo, toggleUpgradeModal } from '@/features/admin-center/admin-center.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { billingApiService } from '@/api/admin-center/billing.api.service';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
import { setSession } from '@/utils/session-helper';
// Extend Window interface to include Paddle
declare global {
interface Window {
Paddle?: {
Environment: { set: (env: string) => void };
Setup: (config: { vendor: number; eventCallback: (data: any) => void }) => void;
Checkout: { open: (params: any) => void };
};
}
}
declare const Paddle: any;
const UpgradePlans = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const [plans, setPlans] = useState<IPricingPlans>({});
const [selectedPlan, setSelectedCard] = useState(IPaddlePlans.ANNUAL);
const [selectedSeatCount, setSelectedSeatCount] = useState(5);
const [seatCountOptions, setSeatCountOptions] = useState<number[]>([]);
const [switchingToFreePlan, setSwitchingToFreePlan] = useState(false);
const [switchingToPaddlePlan, setSwitchingToPaddlePlan] = useState(false);
const [form] = Form.useForm();
const currentSession = useAuthService().getCurrentSession();
const paddlePlans = IPaddlePlans;
const { billingInfo } = useAppSelector(state => state.adminCenterReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [paddleLoading, setPaddleLoading] = useState(false);
const [paddleError, setPaddleError] = useState<string | null>(null);
const populateSeatCountOptions = (currentSeats: number) => {
if (!currentSeats) return [];
const step = 5;
const maxSeats = 90;
const minValue = Math.min(currentSeats + 1);
const rangeStart = Math.ceil(minValue / step) * step;
const range = Array.from(
{ length: Math.floor((maxSeats - rangeStart) / step) + 1 },
(_, i) => rangeStart + i * step
);
return currentSeats < step
? [...Array.from({ length: rangeStart - minValue }, (_, i) => minValue + i), ...range]
: range;
};
const fetchPricingPlans = async () => {
try {
const res = await adminCenterApiService.getPlans();
if (res.done) {
setPlans(res.body);
}
} catch (error) {
logger.error('Error fetching pricing plans', error);
}
};
const switchToFreePlan = async () => {
const teamId = currentSession?.team_id;
if (!teamId) return;
try {
setSwitchingToFreePlan(true);
const res = await adminCenterApiService.switchToFreePlan(teamId);
if (res.done) {
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
const authorizeResponse = await authApiService.verify();
if (authorizeResponse.authenticated) {
setSession(authorizeResponse.user);
dispatch(setUser(authorizeResponse.user));
window.location.href = '/worklenz/admin-center/billing';
}
}
} catch (error) {
logger.error('Error switching to free plan', error);
} finally {
setSwitchingToFreePlan(false);
}
};
const handlePaddleCallback = (data: any) => {
console.log('Paddle event:', data);
switch (data.event) {
case 'Checkout.Loaded':
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
break;
case 'Checkout.Complete':
message.success('Subscription updated successfully!');
setPaddleLoading(true);
setTimeout(() => {
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
}, 10000);
break;
case 'Checkout.Close':
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
// User closed the checkout without completing
// message.info('Checkout was closed without completing the subscription');
break;
case 'Checkout.Error':
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError(data.error?.message || 'An error occurred during checkout');
message.error('Error during checkout: ' + (data.error?.message || 'Unknown error'));
logger.error('Paddle checkout error', data.error);
break;
default:
// Handle other events if needed
break;
}
};
const initializePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
setPaddleLoading(true);
setPaddleError(null);
// Check if Paddle is already loaded
if (window.Paddle) {
configurePaddle(data);
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.paddle.com/paddle/paddle.js';
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
configurePaddle(data);
};
script.onerror = () => {
setPaddleLoading(false);
setPaddleError('Failed to load Paddle checkout');
message.error('Failed to load payment processor');
logger.error('Failed to load Paddle script');
};
document.getElementsByTagName('head')[0].appendChild(script);
};
const configurePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
try {
if (data.sandbox) Paddle.Environment.set('sandbox');
Paddle.Setup({
vendor: parseInt(data.vendor_id),
eventCallback: (eventData: any) => {
void handlePaddleCallback(eventData);
},
});
Paddle.Checkout.open(data.params);
} catch (error) {
setPaddleLoading(false);
setPaddleError('Failed to initialize checkout');
message.error('Failed to initialize checkout');
logger.error('Error initializing Paddle', error);
}
};
const upgradeToPaddlePlan = async (planId: string) => {
try {
setSwitchingToPaddlePlan(true);
setPaddleLoading(true);
setPaddleError(null);
if (billingInfo?.trial_in_progress && billingInfo.status === SUBSCRIPTION_STATUS.TRIALING) {
const res = await billingApiService.upgradeToPaidPlan(planId, selectedSeatCount);
if (res.done) {
initializePaddle(res.body);
} else {
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError('Failed to prepare checkout');
message.error('Failed to prepare checkout');
}
} else if (billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE) {
// For existing subscriptions, use changePlan endpoint
const res = await adminCenterApiService.changePlan(planId);
if (res.done) {
message.success('Subscription plan changed successfully!');
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
} else {
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError('Failed to change plan');
message.error('Failed to change subscription plan');
}
}
} catch (error) {
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError('Error upgrading to paid plan');
message.error('Failed to upgrade to paid plan');
logger.error('Error upgrading to paddle plan', error);
}
};
const continueWithPaddlePlan = async () => {
if (selectedPlan && selectedSeatCount.toString() === '100+') {
message.info('Please contact sales for custom pricing on large teams');
return;
}
try {
setSwitchingToPaddlePlan(true);
setPaddleError(null);
let planId: string | null = null;
if (selectedPlan === paddlePlans.ANNUAL && plans.annual_plan_id) {
planId = plans.annual_plan_id;
} else if (selectedPlan === paddlePlans.MONTHLY && plans.monthly_plan_id) {
planId = plans.monthly_plan_id;
}
if (planId) {
upgradeToPaddlePlan(planId);
} else {
setSwitchingToPaddlePlan(false);
setPaddleError('Invalid plan selected');
message.error('Invalid plan selected');
}
} catch (error) {
setSwitchingToPaddlePlan(false);
setPaddleError('Error processing request');
message.error('Error processing request');
logger.error('Error upgrading to paddle plan', error);
}
};
const isSelected = (cardIndex: IPaddlePlans) =>
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
const cardStyles = {
title: {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
justifyContent: 'center',
},
priceContainer: {
display: 'grid',
gridTemplateColumns: 'auto',
rowGap: '10px',
padding: '20px 20px 0',
},
featureList: {
display: 'grid',
gridTemplateRows: 'auto auto auto',
gridTemplateColumns: '200px',
rowGap: '7px',
padding: '10px',
justifyItems: 'start',
alignItems: 'start',
},
checkIcon: { color: '#52c41a' },
};
const calculateAnnualTotal = (price: string | undefined) => {
if (!price) return;
return (12 * parseFloat(price) * selectedSeatCount).toFixed(2);
};
const calculateMonthlyTotal = (price: string | undefined) => {
if (!price) return;
return (parseFloat(price) * selectedSeatCount).toFixed(2);
};
useEffect(() => {
fetchPricingPlans();
if (billingInfo?.total_used) {
setSeatCountOptions(populateSeatCountOptions(billingInfo.total_used));
form.setFieldsValue({ seatCount: selectedSeatCount });
}
}, [billingInfo]);
const renderFeature = (text: string) => (
<div>
<CheckCircleFilled style={cardStyles.checkIcon} />
&nbsp;<span>{text}</span>
</div>
);
useEffect(() => {
// Cleanup Paddle script when component unmounts
return () => {
const paddleScript = document.querySelector('script[src*="paddle.js"]');
if (paddleScript) {
paddleScript.remove();
}
};
}, []);
return (
<div>
<Flex justify="center" align="center">
<Typography.Title level={2}>
{billingInfo?.status === SUBSCRIPTION_STATUS.TRIALING
? t('selectPlan')
: t('changeSubscriptionPlan')}
</Typography.Title>
</Flex>
<Flex justify="center" align="center">
<Form form={form}>
<Form.Item name="seatCount" label={t('noOfSeats')}>
<Select
style={{ width: 100 }}
value={selectedSeatCount}
options={seatCountOptions.map(option => ({
value: option,
text: option.toString(),
}))}
onChange={setSelectedSeatCount}
/>
</Form.Item>
</Form>
</Flex>
<Flex>
<Row className="w-full">
{/* Free Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.FREE), height: '100%' }}
hoverable
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.FREE)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ 0.00</Typography.Title>
<Typography.Text>{t('freeForever')}</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
{t('bestForPersonalUse')}
</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList}>
{renderFeature(`${plans.free_tier_storage} ${t('storage')}`)}
{renderFeature(`${plans.projects_limit} ${t('projects')}`)}
{renderFeature(`${plans.team_member_limit} ${t('teamMembers')}`)}
</div>
</Card>
</Col>
{/* Annual Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
hoverable
title={
<span style={cardStyles.title}>
{t('annualPlan')}{' '}
<Tag color="volcano" style={{ lineHeight: '21px' }}>
{t('tag')}
</Tag>
</span>
}
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
<Typography.Text>seat / month</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
Total ${calculateAnnualTotal(plans.annual_price)}/ year
<Tooltip
title={
'$' + plans.annual_price + ' x 12 months x ' + selectedSeatCount + ' seats'
}
>
<InfoCircleOutlined
style={{ color: 'grey', fontSize: '16px', marginLeft: '4px' }}
/>
</Tooltip>
</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text>{t('billedAnnually')}</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList} className="mt-4">
{renderFeature(t('startupText01'))}
{renderFeature(t('startupText02'))}
{renderFeature(t('startupText03'))}
{renderFeature(t('startupText04'))}
{renderFeature(t('startupText05'))}
</div>
</Card>
</Col>
{/* Monthly Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.MONTHLY), height: '100%' }}
hoverable
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ {plans.monthly_price}</Typography.Title>
<Typography.Text>seat / month</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
Total ${calculateMonthlyTotal(plans.monthly_price)}/ month
<Tooltip
title={'$' + plans.monthly_price + ' x ' + selectedSeatCount + ' seats'}
>
<InfoCircleOutlined
style={{ color: 'grey', fontSize: '16px', marginLeft: '4px' }}
/>
</Tooltip>
</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text>{t('billedMonthly')}</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList}>
{renderFeature(t('startupText01'))}
{renderFeature(t('startupText02'))}
{renderFeature(t('startupText03'))}
{renderFeature(t('startupText04'))}
{renderFeature(t('startupText05'))}
</div>
</Card>
</Col>
</Row>
</Flex>
{paddleError && (
<Row justify="center" className="mt-2">
<Typography.Text type="danger">{paddleError}</Typography.Text>
</Row>
)}
<Row justify="end" className="mt-4">
{selectedPlan === paddlePlans.FREE && (
<Button
type="primary"
htmlType="submit"
loading={switchingToFreePlan}
onClick={switchToFreePlan}
>
Try for free
</Button>
)}
{selectedPlan === paddlePlans.ANNUAL && (
<Button
type="primary"
htmlType="submit"
loading={switchingToPaddlePlan || paddleLoading}
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.annual_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('annualPlan')}) : t('continueWith', {plan: t('annualPlan')})}
</Button>
)}
{selectedPlan === paddlePlans.MONTHLY && (
<Button
type="primary"
htmlType="submit"
loading={switchingToPaddlePlan || paddleLoading}
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('monthlyPlan')}) : t('continueWith', {plan: t('monthlyPlan')})}
</Button>
)}
</Row>
</div>
);
};
export default UpgradePlans;

View File

@@ -0,0 +1,222 @@
import { Button, Card, Col, Divider, Form, Input, notification, Row, Select } from 'antd';
import React, { useEffect, useState } from 'react';
import { RootState } from '../../../app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IBillingConfigurationCountry } from '@/types/admin-center/country.types';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingConfiguration } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
const Configuration: React.FC = () => {
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [countries, setCountries] = useState<IBillingConfigurationCountry[]>([]);
const [configuration, setConfiguration] = useState<IBillingConfiguration>();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const fetchCountries = async () => {
try {
const res = await adminCenterApiService.getCountries();
if (res.done) {
setCountries(res.body);
}
} catch (error) {
logger.error('Error fetching countries:', error);
}
};
const fetchConfiguration = async () => {
const res = await adminCenterApiService.getBillingConfiguration();
if (res.done) {
setConfiguration(res.body);
form.setFieldsValue(res.body);
}
};
useEffect(() => {
fetchCountries();
fetchConfiguration();
}, []);
const handleSave = async (values: any) => {
try {
setLoading(true);
const res = await adminCenterApiService.updateBillingConfiguration(values);
if (res.done) {
fetchConfiguration();
}
} catch (error) {
logger.error('Error updating configuration:', error);
} finally {
setLoading(false);
}
};
const countryOptions = countries.map(country => ({
label: country.name,
value: country.id,
}));
return (
<div>
<Card
title={
<span
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
}}
>
Billing Details
</span>
}
style={{ marginTop: '16px' }}
>
<Form
form={form}
initialValues={configuration}
onFinish={handleSave}
>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
name="name"
label="Name"
layout="vertical"
rules={[
{
required: true,
},
]}
>
<Input placeholder="Name" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
name="email"
label="Email Address"
layout="vertical"
rules={[
{
required: true,
},
]}
>
<Input placeholder="Name" disabled />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
name="phone"
label="Contact Number"
layout="vertical"
rules={[
{
pattern: /^\d{10}$/,
message: 'Phone number must be exactly 10 digits',
},
]}
>
<Input
placeholder="Phone Number"
maxLength={10}
onInput={e => {
const input = e.target as HTMLInputElement; // Type assertion to access 'value'
input.value = input.value.replace(/[^0-9]/g, ''); // Restrict non-numeric input
}}
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left" style={{ margin: '16px 0' }}>
<span
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 600,
fontSize: '16px',
display: 'flex',
gap: '4px',
}}
>
Company Details
</span>
</Divider>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="company_name" label="Company Name" layout="vertical">
<Input placeholder="Company Name" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="address_line_1" label="Address Line 01" layout="vertical">
<Input placeholder="Address Line 01" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="address_line_2" label="Address Line 02" layout="vertical">
<Input placeholder="Address Line 02" />
</Form.Item>
</Col>
</Row>
<Row>
<Col
span={8}
style={{
padding: '0 12px',
height: '86px',
scrollbarColor: 'red',
}}
>
<Form.Item name="country" label="Country" layout="vertical">
<Select
dropdownStyle={{ maxHeight: 256, overflow: 'auto' }}
placement="topLeft"
showSearch
placeholder="Country"
optionFilterProp="label"
allowClear
options={countryOptions}
/>
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="city" label="City" layout="vertical">
<Input placeholder="City" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="state" label="State" layout="vertical">
<Input placeholder="State" />
</Form.Item>
</Col>
</Row>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="postal_code" label="Postal Code" layout="vertical">
<Input placeholder="Postal Code" />
</Form.Item>
</Col>
</Row>
<Row>
<Col style={{ paddingLeft: '12px' }}>
<Form.Item>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
</div>
);
};
export default Configuration;

View File

@@ -0,0 +1,60 @@
import { Table, TableProps, Typography } from 'antd';
import React, { useMemo } from 'react';
import { IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
interface OrganizationAdminsTableProps {
organizationAdmins: IOrganizationAdmin[] | null;
loading: boolean;
themeMode: string;
}
const { Text } = Typography;
const OrganizationAdminsTable: React.FC<OrganizationAdminsTableProps> = ({
organizationAdmins,
loading,
themeMode,
}) => {
const columns = useMemo<TableProps<IOrganizationAdmin>['columns']>(
() => [
{
title: <Text strong>Name</Text>,
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<div>
<Text>
{text}
{record.is_owner && <Text> (Owner)</Text>}
</Text>
</div>
),
},
{
title: <Text strong>Email</Text>,
dataIndex: 'email',
key: 'email',
render: text => <Text>{text}</Text>,
},
],
[]
);
return (
<Table<IOrganizationAdmin>
className="organization-admins-table"
columns={columns}
dataSource={organizationAdmins || []}
loading={loading}
showHeader={false}
pagination={{
size: 'small',
pageSize: 10,
hideOnSinglePage: true,
}}
rowKey="email"
/>
);
};
export default OrganizationAdminsTable;

View File

@@ -0,0 +1,122 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { EnterOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Button, Tooltip, Typography } from 'antd';
import TextArea from 'antd/es/input/TextArea';
import Paragraph from 'antd/es/typography/Paragraph';
import { TFunction } from 'i18next';
import { useState, useEffect } from 'react';
interface OrganizationNameProps {
themeMode: string;
name: string;
t: TFunction;
refetch: () => void;
}
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
const [isEditable, setIsEditable] = useState(false);
const [newName, setNewName] = useState(name);
useEffect(() => {
setNewName(name);
}, [name]);
const handleBlur = () => {
if (newName.trim() === '') {
setNewName(name);
setIsEditable(false);
return;
}
if (newName !== name) {
updateOrganizationName();
}
setIsEditable(false);
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewName(e.target.value);
};
const updateOrganizationName = async () => {
try {
const trimmedName = newName.trim();
const res = await adminCenterApiService.updateOrganizationName({ name: trimmedName });
if (res.done) {
refetch();
}
} catch (error) {
logger.error('Error updating organization name', error);
setNewName(name);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setNewName(name);
setIsEditable(false);
}
};
return (
<Card>
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
{t('name')}
</Typography.Title>
<div style={{ paddingTop: '8px' }}>
<div style={{ marginBottom: '8px' }}>
{isEditable ? (
<div style={{ position: 'relative' }}>
<TextArea
style={{
height: '32px',
paddingRight: '40px',
resize: 'none',
borderRadius: '4px',
}}
onPressEnter={handleBlur}
value={newName}
onChange={handleNameChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoFocus
maxLength={100}
placeholder={t('enterOrganizationName')}
/>
<Button
icon={<EnterOutlined style={{ color: '#1890ff' }} />}
type="text"
style={{
position: 'absolute',
right: '4px',
top: '50%',
transform: 'translateY(-50%)',
padding: '4px 8px',
color: '#1890ff',
}}
onClick={handleBlur}
/>
</div>
) : (
<Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{name}
<Tooltip title={t('edit')}>
<Button
onClick={() => setIsEditable(true)}
size="small"
type="text"
icon={<EditOutlined />}
style={{ padding: '4px', color: '#1890ff' }}
/>
</Tooltip>
</div>
</Typography.Text>
)}
</div>
</div>
</Card>
);
};
export default OrganizationName;

View File

@@ -0,0 +1,120 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IOrganization } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { MailOutlined, PhoneOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Tooltip, Input, Button, Typography, InputRef } from 'antd';
import { TFunction } from 'i18next';
import { useEffect, useRef, useState } from 'react';
interface OrganizationOwnerProps {
themeMode: string;
organization: IOrganization | null;
t: TFunction;
refetch: () => void;
}
const OrganizationOwner = ({ themeMode, organization, t, refetch }: OrganizationOwnerProps) => {
const [isEditableContactNumber, setIsEditableContactNumber] = useState(false);
const [number, setNumber] = useState(organization?.contact_number || '');
const contactNoRef = useRef<InputRef>(null);
const handleContactNumberBlur = () => {
setIsEditableContactNumber(false);
updateOrganizationContactNumber();
};
const updateOrganizationContactNumber = async () => {
try {
const res = await adminCenterApiService.updateOwnerContactNumber({ contact_number: number });
if (res.done) {
refetch();
}
} catch (error) {
logger.error('Error updating organization contact number:', error);
}
};
const addContactNumber = () => {
setIsEditableContactNumber(true);
setTimeout(() => {
contactNoRef.current?.focus();
}, 500);
};
const handleEditContactNumber = () => {
setIsEditableContactNumber(true);
};
const handleContactNumber = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
setNumber(inputValue);
};
return (
<Card>
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
{t('owner')}
</Typography.Title>
<div style={{ paddingTop: '8px' }}>
<div style={{ marginBottom: '8px' }}>
<Typography.Text
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
}}
>
{organization?.owner_name || ''}
</Typography.Text>
</div>
</div>
<Typography.Paragraph style={{ display: 'flex', alignItems: 'center', margin: 0 }}>
<Typography.Text
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
}}
>
<span style={{ marginRight: '8px' }}>
<Tooltip title="Email Address">
<MailOutlined />
</Tooltip>
</span>
{organization?.email || ''}
</Typography.Text>
</Typography.Paragraph>
<Typography.Paragraph style={{ marginTop: '0.5rem', marginBottom: 0 }}>
<Tooltip title="Contact Number">
<span style={{ marginRight: '8px' }}>
<PhoneOutlined />
</span>
</Tooltip>
{isEditableContactNumber ? (
<Input
onChange={handleContactNumber}
onPressEnter={handleContactNumberBlur}
onBlur={handleContactNumberBlur}
style={{ width: '200px' }}
value={number}
type="text"
maxLength={15}
ref={contactNoRef}
/>
) : number === '' ? (
<Typography.Link onClick={addContactNumber}>{t('contactNumber')}</Typography.Link>
) : (
<Typography.Text>
{number}
<Tooltip title="Edit">
<Button
onClick={handleEditContactNumber}
size="small"
type="link"
icon={<EditOutlined />}
/>
</Tooltip>
</Typography.Text>
)}
</Typography.Paragraph>
</Card>
);
};
export default OrganizationOwner;

View File

@@ -0,0 +1,85 @@
import React, { useRef, useState } from 'react';
import { Button, Drawer, Form, Input, InputRef, Typography } from 'antd';
import { fetchTeams } from '@features/teams/teamSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import logger from '@/utils/errorLogger';
import { teamsApiService } from '@/api/teams/teams.api.service';
interface AddTeamDrawerProps {
isDrawerOpen: boolean;
onClose: () => void;
reloadTeams: () => void;
}
const AddTeamDrawer: React.FC<AddTeamDrawerProps> = ({ isDrawerOpen, onClose, reloadTeams }) => {
const { t } = useTranslation('admin-center/teams');
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const addTeamNameInputRef = useRef<InputRef>(null);
const handleFormSubmit = async (values: any) => {
if (!values.name || values.name.trim() === '') return;
try {
setCreating(true);
const newTeam = {
name: values.name,
};
const res = await teamsApiService.createTeam(newTeam);
if (res.done) {
onClose();
reloadTeams();
dispatch(fetchTeams());
}
} catch (error) {
logger.error('Error adding team', error);
} finally {
setCreating(false);
}
form.resetFields();
};
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('drawerTitle')}
</Typography.Text>
}
open={isDrawerOpen}
destroyOnClose
afterOpenChange={() => {
setTimeout(() => {
addTeamNameInputRef.current?.focus();
}, 100);
}}
onClose={onClose}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="name"
label={t('label')}
rules={[
{
required: true,
message: t('message'),
},
]}
>
<Input placeholder={t('drawerPlaceholder')} ref={addTeamNameInputRef} />
</Form.Item>
<Form.Item>
<Button type="primary" style={{ width: '100%' }} htmlType="submit" loading={creating}>
{t('create')}
</Button>
</Form.Item>
</Form>
</Drawer>
);
};
export default AddTeamDrawer;

View File

@@ -0,0 +1,4 @@
.setting-team-table .ant-table-thead > tr > th,
.setting-team-table .ant-table-tbody > tr > td {
padding: 12px 10px !important;
}

View File

@@ -0,0 +1,198 @@
import {
Avatar,
Button,
Drawer,
Flex,
Form,
Input,
message,
Select,
Table,
TableProps,
Typography,
} from 'antd';
import React, { useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleSettingDrawer, updateTeam } from '@/features/teams/teamSlice';
import { TeamsType } from '@/types/admin-center/team.types';
import './settings-drawer.css';
import CustomAvatar from '@/components/CustomAvatar';
import { teamsApiService } from '@/api/teams/teams.api.service';
import logger from '@/utils/errorLogger';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
IOrganizationTeam,
IOrganizationTeamMember,
} from '@/types/admin-center/admin-center.types';
import Avatars from '@/components/avatars/avatars';
import { AvatarNamesMap } from '@/shared/constants';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useTranslation } from 'react-i18next';
interface SettingTeamDrawerProps {
teamId: string;
isSettingDrawerOpen: boolean;
setIsSettingDrawerOpen: (value: boolean) => void;
}
const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
teamId,
isSettingDrawerOpen,
setIsSettingDrawerOpen,
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/teams');
const [form] = Form.useForm();
const [teamData, setTeamData] = useState<IOrganizationTeam | null>(null);
const [loadingTeamMembers, setLoadingTeamMembers] = useState(false);
const [updatingTeam, setUpdatingTeam] = useState(false);
const [total, setTotal] = useState(0);
const fetchTeamMembers = async () => {
if (!teamId) return;
try {
setLoadingTeamMembers(true);
const res = await adminCenterApiService.getOrganizationTeam(teamId);
if (res.done) {
setTeamData(res.body);
setTotal(res.body.team_members?.length || 0);
form.setFieldsValue({ name: res.body.name || '' });
}
} catch (error) {
logger.error('Error fetching team members', error);
} finally {
setLoadingTeamMembers(false);
}
};
const handleFormSubmit = async (values: any) => {
console.log(values);
// const newTeam: TeamsType = {
// teamId: teamId,
// teamName: values.name,
// membersCount: team?.membersCount || 1,
// members: team?.members || ['Raveesha Dilanka'],
// owner: values.name,
// created: team?.created || new Date(),
// isActive: false,
// };
// dispatch(updateTeam(newTeam));
// dispatch(toggleSettingDrawer());
// form.resetFields();
// message.success('Team updated!');
};
const roleOptions = [
{ value: 'Admin', label: t('admin') },
{ value: 'Member', label: t('member') },
{ value: 'Owner', label: t('owner') },
];
const columns: TableProps['columns'] = [
{
title: t('user'),
key: 'user',
render: (_, record: IOrganizationTeamMember) => (
<Flex align="center" gap="8px" key={record.id}>
<SingleAvatar avatarUrl={record.avatar_url} name={record.name} />
<Typography.Text>{record.name || ''}</Typography.Text>
</Flex>
),
},
{
title: t('role'),
key: 'role',
render: (_, record: IOrganizationTeamMember) => (
<div>
<Select
style={{ width: '150px', height: '32px' }}
options={roleOptions.map(option => ({ ...option, key: option.value }))}
defaultValue={record.role_name || ''}
disabled={record.role_name === 'Owner'}
/>
</div>
),
},
];
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('teamSettings')}
</Typography.Text>
}
width={550}
open={isSettingDrawerOpen}
onClose={() => {
form.resetFields();
setTeamData(null);
setTimeout(() => {
setIsSettingDrawerOpen(false);
}, 100);
}}
destroyOnClose
afterOpenChange={open => {
if (open) {
form.resetFields();
setTeamData(null);
fetchTeamMembers();
}
}}
footer={
<Flex justify="end">
<Button type="primary" onClick={form.submit} loading={updatingTeam}>
{t('update')}
</Button>
</Flex>
}
>
<Form
form={form}
layout="vertical"
onFinish={handleFormSubmit}
initialValues={{
name: teamData?.name,
}}
>
<Form.Item
name="name"
key="name"
label={t('teamName')}
rules={[
{
required: true,
message: t('message'),
},
]}
>
<Input placeholder={t('teamNamePlaceholder')} />
</Form.Item>
<Form.Item
name="users"
label={
<span>
{t('members')} ({teamData?.team_members?.length})
</span>
}
>
<Table
className="setting-team-table"
style={{ marginBottom: '24px' }}
columns={columns}
dataSource={teamData?.team_members?.map(member => ({ ...member, key: member.id }))}
pagination={false}
loading={loadingTeamMembers}
rowKey={record => record.team_member_id}
size="small"
/>
</Form.Item>
</Form>
</Drawer>
);
};
export default SettingTeamDrawer;

View File

@@ -0,0 +1,146 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import Avatars from '@/components/avatars/avatars';
import SettingTeamDrawer from '@/components/admin-center/teams/settings-drawer/settings-drawer';
import { toggleSettingDrawer, deleteTeam, fetchTeams } from '@/features/teams/teamSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { SettingOutlined, DeleteOutlined } from '@ant-design/icons';
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from 'antd';
import { TFunction } from 'i18next';
import { useState } from 'react';
import { useMediaQuery } from 'react-responsive';
interface TeamsTableProps {
teams: IOrganizationTeam[];
currentTeam: IOrganizationTeam | null;
t: TFunction;
loading: boolean;
reloadTeams: () => void;
}
const TeamsTable: React.FC<TeamsTableProps> = ({
teams,
currentTeam = null,
t,
loading,
reloadTeams,
}) => {
const dispatch = useAppDispatch();
const isTablet = useMediaQuery({ query: '(min-width: 1000px)' });
const [deleting, setDeleting] = useState(false);
const [isSettingDrawerOpen, setIsSettingDrawerOpen] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<string>('');
const handleTeamDelete = async (teamId: string) => {
if (!teamId) return;
try {
setDeleting(true);
const res = await adminCenterApiService.deleteTeam(teamId);
if (res.done) {
reloadTeams();
dispatch(fetchTeams());
}
} catch (error) {
logger.error('Error deleting team', error);
} finally {
setDeleting(false);
}
};
const columns: TableProps['columns'] = [
{
title: t('team'),
key: 'teamName',
render: (record: IOrganizationTeam) => (
<Typography.Text style={{ fontSize: `${isTablet ? '14px' : '10px'}` }}>
<Badge
status={currentTeam?.id === record.id ? 'success' : 'default'}
style={{ marginRight: '8px' }}
/>
{record.name}
</Typography.Text>
),
},
{
title: <span style={{ display: 'flex', justifyContent: 'center' }}>{t('membersCount')}</span>,
key: 'membersCount',
render: (record: IOrganizationTeam) => (
<Typography.Text
style={{
display: 'flex',
justifyContent: 'center',
fontSize: `${isTablet ? '14px' : '10px'}`,
}}
>
{record.members_count}
</Typography.Text>
),
},
{
title: t('members'),
key: 'members',
render: (record: IOrganizationTeam) => (
<span>
<Avatars members={record.names} />
</span>
),
},
{
title: '',
key: 'button',
render: (record: IOrganizationTeam) => (
<div className="row-buttons">
<Tooltip title={t('settings')}>
<Button
style={{ marginRight: '8px' }}
size="small"
onClick={() => {
setSelectedTeam(record.id || '');
setIsSettingDrawerOpen(true);
}}
>
<SettingOutlined />
</Button>
</Tooltip>
<Tooltip title={t('delete')}>
<Popconfirm title={t('popTitle')} onConfirm={() => handleTeamDelete(record.id || '')}>
<Button size="small">
<DeleteOutlined />
</Button>
</Popconfirm>
</Tooltip>
</div>
),
},
];
return (
<>
<Card>
<Table
rowClassName="team-table-row"
className="team-table"
size="small"
columns={columns}
dataSource={teams}
rowKey={record => record.id}
loading={loading}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
}}
/>
</Card>
<SettingTeamDrawer
teamId={selectedTeam}
isSettingDrawerOpen={isSettingDrawerOpen}
setIsSettingDrawerOpen={setIsSettingDrawerOpen}
/>
</>
);
};
export default TeamsTable;