init
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
<Typography.Text>
|
||||
{billingInfo?.billing_type === 'year'
|
||||
? billingInfo.unit_price_per_month
|
||||
: billingInfo?.unit_price}
|
||||
|
||||
{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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.upgrade-plans .ant-card-head-wrapper {
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
<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;
|
||||
@@ -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} />
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
.setting-team-table .ant-table-thead > tr > th,
|
||||
.setting-team-table .ant-table-tbody > tr > td {
|
||||
padding: 12px 10px !important;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user