feat(billing): Add billing-usage frontend module with components, pages and routes
- Add billing-usage feature module with types, API clients, hooks, and components - Create PlanCard, PlanSelector, SubscriptionStatusBadge, InvoiceList, UsageSummaryCard, CouponInput components - Add BillingPage, PlansPage, InvoicesPage, UsagePage - Update routes to include billing section Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b996680777
commit
28b27565f8
@ -29,6 +29,12 @@ const PartnerDetailPage = lazy(() => import('@pages/partners/PartnerDetailPage')
|
||||
const PartnerCreatePage = lazy(() => import('@pages/partners/PartnerCreatePage'));
|
||||
const PartnerEditPage = lazy(() => import('@pages/partners/PartnerEditPage'));
|
||||
|
||||
// Billing pages
|
||||
const BillingPage = lazy(() => import('@pages/billing/BillingPage').then(m => ({ default: m.BillingPage })));
|
||||
const PlansPage = lazy(() => import('@pages/billing/PlansPage').then(m => ({ default: m.PlansPage })));
|
||||
const InvoicesPage = lazy(() => import('@pages/billing/InvoicesPage').then(m => ({ default: m.InvoicesPage })));
|
||||
const UsagePage = lazy(() => import('@pages/billing/UsagePage').then(m => ({ default: m.UsagePage })));
|
||||
|
||||
function LazyWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
|
||||
}
|
||||
@ -249,6 +255,48 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
},
|
||||
|
||||
// Billing routes
|
||||
{
|
||||
path: '/billing',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<BillingPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/billing/plans',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<PlansPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/billing/invoices',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<InvoicesPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/billing/usage',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<UsagePage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/billing/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Sección de facturación - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
// Error pages
|
||||
{
|
||||
path: '/unauthorized',
|
||||
|
||||
86
src/features/billing-usage/api/coupons.api.ts
Normal file
86
src/features/billing-usage/api/coupons.api.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
Coupon,
|
||||
CreateCouponDto,
|
||||
UpdateCouponDto,
|
||||
CouponValidation,
|
||||
CouponFilters,
|
||||
CouponsResponse,
|
||||
CouponStats,
|
||||
CouponRedemption,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/billing/coupons';
|
||||
|
||||
export const couponsApi = {
|
||||
getAll: async (filters?: CouponFilters): Promise<CouponsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.discountType) params.append('discountType', filters.discountType);
|
||||
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<CouponsResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Coupon> => {
|
||||
const response = await api.get<Coupon>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<Coupon> => {
|
||||
const response = await api.get<Coupon>(`${BASE_URL}/code/${code}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateCouponDto): Promise<Coupon> => {
|
||||
const response = await api.post<Coupon>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateCouponDto): Promise<Coupon> => {
|
||||
const response = await api.patch<Coupon>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
validate: async (code: string, planId?: string, amount?: number): Promise<CouponValidation> => {
|
||||
const response = await api.post<CouponValidation>(`${BASE_URL}/validate`, {
|
||||
code,
|
||||
planId,
|
||||
amount,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
activate: async (id: string): Promise<Coupon> => {
|
||||
const response = await api.post<Coupon>(`${BASE_URL}/${id}/activate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deactivate: async (id: string): Promise<Coupon> => {
|
||||
const response = await api.post<Coupon>(`${BASE_URL}/${id}/deactivate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStats: async (): Promise<CouponStats> => {
|
||||
const response = await api.get<CouponStats>(`${BASE_URL}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRedemptions: async (
|
||||
couponId: string
|
||||
): Promise<{ data: CouponRedemption[]; total: number }> => {
|
||||
const response = await api.get<{ data: CouponRedemption[]; total: number }>(
|
||||
`${BASE_URL}/${couponId}/redemptions`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
5
src/features/billing-usage/api/index.ts
Normal file
5
src/features/billing-usage/api/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { plansApi } from './plans.api';
|
||||
export { subscriptionsApi } from './subscriptions.api';
|
||||
export { invoicesApi } from './invoices.api';
|
||||
export { usageApi } from './usage.api';
|
||||
export { couponsApi } from './coupons.api';
|
||||
91
src/features/billing-usage/api/invoices.api.ts
Normal file
91
src/features/billing-usage/api/invoices.api.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type { Invoice, CreateInvoiceDto, InvoiceFilters, InvoicesResponse } from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/billing/invoices';
|
||||
|
||||
export const invoicesApi = {
|
||||
getAll: async (filters?: InvoiceFilters): Promise<InvoicesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.tenantId) params.append('tenantId', filters.tenantId);
|
||||
if (filters?.companyId) params.append('companyId', filters.companyId);
|
||||
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
|
||||
if (filters?.subscriptionId) params.append('subscriptionId', filters.subscriptionId);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.invoiceType) params.append('invoiceType', filters.invoiceType);
|
||||
if (filters?.invoiceContext) params.append('invoiceContext', filters.invoiceContext);
|
||||
if (filters?.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters?.endDate) params.append('endDate', filters.endDate);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<InvoicesResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Invoice> => {
|
||||
const response = await api.get<Invoice>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByNumber: async (invoiceNumber: string): Promise<Invoice> => {
|
||||
const response = await api.get<Invoice>(`${BASE_URL}/number/${invoiceNumber}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSaasInvoices: async (filters?: InvoiceFilters): Promise<InvoicesResponse> => {
|
||||
return invoicesApi.getAll({ ...filters, invoiceContext: 'saas' });
|
||||
},
|
||||
|
||||
create: async (data: CreateInvoiceDto): Promise<Invoice> => {
|
||||
const response = await api.post<Invoice>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
issue: async (id: string): Promise<Invoice> => {
|
||||
const response = await api.post<Invoice>(`${BASE_URL}/${id}/issue`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsPaid: async (id: string, paymentDate?: string): Promise<Invoice> => {
|
||||
const response = await api.post<Invoice>(`${BASE_URL}/${id}/mark-paid`, { paymentDate });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancel: async (id: string, reason?: string): Promise<Invoice> => {
|
||||
const response = await api.post<Invoice>(`${BASE_URL}/${id}/cancel`, { reason });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
download: async (id: string): Promise<Blob> => {
|
||||
const response = await api.get<Blob>(`${BASE_URL}/${id}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
sendByEmail: async (id: string, email?: string): Promise<void> => {
|
||||
await api.post(`${BASE_URL}/${id}/send-email`, { email });
|
||||
},
|
||||
|
||||
getUpcoming: async (): Promise<Invoice | null> => {
|
||||
try {
|
||||
const response = await api.get<Invoice>(`${BASE_URL}/upcoming`);
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getSummary: async (
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<{ total: number; paid: number; pending: number; overdue: number }> => {
|
||||
const response = await api.get<{ total: number; paid: number; pending: number; overdue: number }>(
|
||||
`${BASE_URL}/summary`,
|
||||
{ params: { startDate, endDate } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
71
src/features/billing-usage/api/plans.api.ts
Normal file
71
src/features/billing-usage/api/plans.api.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
SubscriptionPlan,
|
||||
CreatePlanDto,
|
||||
UpdatePlanDto,
|
||||
PlanFilters,
|
||||
PlansResponse,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/billing/plans';
|
||||
|
||||
export const plansApi = {
|
||||
getAll: async (filters?: PlanFilters): Promise<PlansResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.planType) params.append('planType', filters.planType);
|
||||
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||
if (filters?.isPublic !== undefined) params.append('isPublic', String(filters.isPublic));
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<PlansResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPublic: async (): Promise<SubscriptionPlan[]> => {
|
||||
const response = await api.get<SubscriptionPlan[]>(`${BASE_URL}/public`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<SubscriptionPlan> => {
|
||||
const response = await api.get<SubscriptionPlan>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<SubscriptionPlan> => {
|
||||
const response = await api.get<SubscriptionPlan>(`${BASE_URL}/code/${code}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreatePlanDto): Promise<SubscriptionPlan> => {
|
||||
const response = await api.post<SubscriptionPlan>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdatePlanDto): Promise<SubscriptionPlan> => {
|
||||
const response = await api.patch<SubscriptionPlan>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
activate: async (id: string): Promise<SubscriptionPlan> => {
|
||||
const response = await api.post<SubscriptionPlan>(`${BASE_URL}/${id}/activate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deactivate: async (id: string): Promise<SubscriptionPlan> => {
|
||||
const response = await api.post<SubscriptionPlan>(`${BASE_URL}/${id}/deactivate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
comparePlans: async (planIds: string[]): Promise<SubscriptionPlan[]> => {
|
||||
const response = await api.post<SubscriptionPlan[]>(`${BASE_URL}/compare`, { planIds });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
102
src/features/billing-usage/api/subscriptions.api.ts
Normal file
102
src/features/billing-usage/api/subscriptions.api.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
TenantSubscription,
|
||||
CreateSubscriptionDto,
|
||||
UpdateSubscriptionDto,
|
||||
CancelSubscriptionDto,
|
||||
SubscriptionFilters,
|
||||
SubscriptionsResponse,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/billing/subscriptions';
|
||||
|
||||
export const subscriptionsApi = {
|
||||
getAll: async (filters?: SubscriptionFilters): Promise<SubscriptionsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.tenantId) params.append('tenantId', filters.tenantId);
|
||||
if (filters?.planId) params.append('planId', filters.planId);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.billingCycle) params.append('billingCycle', filters.billingCycle);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<SubscriptionsResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<TenantSubscription> => {
|
||||
const response = await api.get<TenantSubscription>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCurrent: async (): Promise<TenantSubscription> => {
|
||||
const response = await api.get<TenantSubscription>(`${BASE_URL}/current`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByTenant: async (tenantId: string): Promise<TenantSubscription> => {
|
||||
const response = await api.get<TenantSubscription>(`${BASE_URL}/tenant/${tenantId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateSubscriptionDto): Promise<TenantSubscription> => {
|
||||
const response = await api.post<TenantSubscription>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateSubscriptionDto): Promise<TenantSubscription> => {
|
||||
const response = await api.patch<TenantSubscription>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancel: async (id: string, data?: CancelSubscriptionDto): Promise<TenantSubscription> => {
|
||||
const response = await api.post<TenantSubscription>(`${BASE_URL}/${id}/cancel`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reactivate: async (id: string): Promise<TenantSubscription> => {
|
||||
const response = await api.post<TenantSubscription>(`${BASE_URL}/${id}/reactivate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
changePlan: async (
|
||||
id: string,
|
||||
planId: string,
|
||||
billingCycle?: 'monthly' | 'annual'
|
||||
): Promise<TenantSubscription> => {
|
||||
const response = await api.post<TenantSubscription>(`${BASE_URL}/${id}/change-plan`, {
|
||||
planId,
|
||||
billingCycle,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
applyCoupon: async (id: string, couponCode: string): Promise<TenantSubscription> => {
|
||||
const response = await api.post<TenantSubscription>(`${BASE_URL}/${id}/apply-coupon`, {
|
||||
couponCode,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBillingInfo: async (
|
||||
id: string,
|
||||
data: Pick<UpdateSubscriptionDto, 'billingEmail' | 'billingName' | 'billingAddress' | 'taxId'>
|
||||
): Promise<TenantSubscription> => {
|
||||
const response = await api.patch<TenantSubscription>(`${BASE_URL}/${id}/billing-info`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePaymentMethod: async (
|
||||
id: string,
|
||||
paymentMethodId: string,
|
||||
paymentProvider: string
|
||||
): Promise<TenantSubscription> => {
|
||||
const response = await api.patch<TenantSubscription>(`${BASE_URL}/${id}/payment-method`, {
|
||||
paymentMethodId,
|
||||
paymentProvider,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
86
src/features/billing-usage/api/usage.api.ts
Normal file
86
src/features/billing-usage/api/usage.api.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type { UsageTracking, UsageSummary, UsageFilters, UsageResponse } from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/billing/usage';
|
||||
|
||||
export const usageApi = {
|
||||
getAll: async (filters?: UsageFilters): Promise<UsageResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.tenantId) params.append('tenantId', filters.tenantId);
|
||||
if (filters?.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters?.endDate) params.append('endDate', filters.endDate);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<UsageResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCurrent: async (): Promise<UsageTracking | null> => {
|
||||
try {
|
||||
const response = await api.get<UsageTracking>(`${BASE_URL}/current`);
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getByPeriod: async (periodStart: string): Promise<UsageTracking | null> => {
|
||||
try {
|
||||
const response = await api.get<UsageTracking>(`${BASE_URL}/period/${periodStart}`);
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getSummary: async (): Promise<UsageSummary> => {
|
||||
const response = await api.get<UsageSummary>(`${BASE_URL}/summary`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHistory: async (months: number = 12): Promise<UsageTracking[]> => {
|
||||
const response = await api.get<UsageTracking[]>(`${BASE_URL}/history`, {
|
||||
params: { months },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrend: async (
|
||||
metric: 'users' | 'storage' | 'apiCalls' | 'sales',
|
||||
months: number = 6
|
||||
): Promise<{ period: string; value: number }[]> => {
|
||||
const response = await api.get<{ period: string; value: number }[]>(`${BASE_URL}/trend/${metric}`, {
|
||||
params: { months },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
checkLimit: async (
|
||||
limitKey: string
|
||||
): Promise<{ allowed: boolean; current: number; limit: number; percent: number }> => {
|
||||
const response = await api.get<{ allowed: boolean; current: number; limit: number; percent: number }>(
|
||||
`${BASE_URL}/check-limit/${limitKey}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getOverages: async (): Promise<{
|
||||
users: number;
|
||||
branches: number;
|
||||
storage: number;
|
||||
apiCalls: number;
|
||||
totalAmount: number;
|
||||
}> => {
|
||||
const response = await api.get<{
|
||||
users: number;
|
||||
branches: number;
|
||||
storage: number;
|
||||
apiCalls: number;
|
||||
totalAmount: number;
|
||||
}>(`${BASE_URL}/overages`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
198
src/features/billing-usage/components/CouponInput.tsx
Normal file
198
src/features/billing-usage/components/CouponInput.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { CouponValidation } from '../types';
|
||||
|
||||
interface CouponInputProps {
|
||||
onValidate: (code: string) => Promise<CouponValidation | null>;
|
||||
onApply?: (code: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
planId?: string;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export const CouponInput: React.FC<CouponInputProps> = ({
|
||||
onValidate,
|
||||
onApply,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [code, setCode] = useState('');
|
||||
const [validation, setValidation] = useState<CouponValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!code.trim()) return;
|
||||
|
||||
setIsValidating(true);
|
||||
setValidation(null);
|
||||
|
||||
try {
|
||||
const result = await onValidate(code.trim().toUpperCase());
|
||||
setValidation(result);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!onApply || !validation?.isValid) return;
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
await onApply(code.trim().toUpperCase());
|
||||
setApplied(true);
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setCode('');
|
||||
setValidation(null);
|
||||
setApplied(false);
|
||||
};
|
||||
|
||||
const formatDiscount = () => {
|
||||
if (!validation?.coupon) return '';
|
||||
const { discountType, discountValue } = validation.coupon;
|
||||
if (discountType === 'percentage') {
|
||||
return `${discountValue}% de descuento`;
|
||||
}
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: validation.coupon.currency || 'MXN',
|
||||
}).format(discountValue) + ' de descuento';
|
||||
};
|
||||
|
||||
if (applied && validation?.isValid) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-green-800">
|
||||
Cupón {validation.coupon?.code} aplicado
|
||||
</p>
|
||||
<p className="text-sm text-green-600">{formatDiscount()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-green-600 hover:text-green-800"
|
||||
onClick={handleClear}
|
||||
>
|
||||
Cambiar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Código de cupón"
|
||||
disabled={disabled || isValidating || isApplying}
|
||||
className={`
|
||||
w-full rounded-lg border px-4 py-2 text-sm uppercase
|
||||
${validation?.isValid === false
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: validation?.isValid
|
||||
? 'border-green-300 focus:border-green-500 focus:ring-green-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}
|
||||
disabled:bg-gray-100 disabled:text-gray-500
|
||||
`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleValidate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{code && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validation?.isValid && onApply ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isApplying}
|
||||
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:bg-green-300"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{isApplying ? 'Aplicando...' : 'Aplicar'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isValidating || !code.trim()}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-blue-300"
|
||||
onClick={handleValidate}
|
||||
>
|
||||
{isValidating ? 'Validando...' : 'Validar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validation && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
validation.isValid
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{validation.isValid ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<strong>{validation.coupon?.name}</strong>: {formatDiscount()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{validation.message || 'Cupón no válido'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
161
src/features/billing-usage/components/InvoiceList.tsx
Normal file
161
src/features/billing-usage/components/InvoiceList.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { InvoiceStatusBadge } from './InvoiceStatusBadge';
|
||||
import type { Invoice } from '../types';
|
||||
|
||||
interface InvoiceListProps {
|
||||
invoices: Invoice[];
|
||||
isLoading?: boolean;
|
||||
onDownload?: (invoice: Invoice) => void;
|
||||
onView?: (invoice: Invoice) => void;
|
||||
}
|
||||
|
||||
export const InvoiceList: React.FC<InvoiceListProps> = ({
|
||||
invoices,
|
||||
isLoading = false,
|
||||
onDownload,
|
||||
onView,
|
||||
}) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = 'MXN') => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (invoices.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-8 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-gray-500">No hay facturas</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Factura
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Fecha
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Período
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span className="font-medium text-gray-900">{invoice.invoiceNumber}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{invoice.periodStart && invoice.periodEnd ? (
|
||||
<>
|
||||
{formatDate(invoice.periodStart)} - {formatDate(invoice.periodEnd)}
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<InvoiceStatusBadge status={invoice.status} size="sm" />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-medium text-gray-900">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onView && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
onClick={() => onView(invoice)}
|
||||
title="Ver detalles"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
onClick={() => onDownload(invoice)}
|
||||
title="Descargar PDF"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
55
src/features/billing-usage/components/InvoiceStatusBadge.tsx
Normal file
55
src/features/billing-usage/components/InvoiceStatusBadge.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import type { InvoiceStatus } from '../types';
|
||||
|
||||
interface InvoiceStatusBadgeProps {
|
||||
status: InvoiceStatus;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; className: string }> = {
|
||||
draft: {
|
||||
label: 'Borrador',
|
||||
className: 'bg-gray-100 text-gray-700',
|
||||
},
|
||||
issued: {
|
||||
label: 'Emitida',
|
||||
className: 'bg-blue-100 text-blue-700',
|
||||
},
|
||||
paid: {
|
||||
label: 'Pagada',
|
||||
className: 'bg-green-100 text-green-700',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelada',
|
||||
className: 'bg-red-100 text-red-700',
|
||||
},
|
||||
overdue: {
|
||||
label: 'Vencida',
|
||||
className: 'bg-orange-100 text-orange-700',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
export const InvoiceStatusBadge: React.FC<InvoiceStatusBadgeProps> = ({
|
||||
status,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full font-medium
|
||||
${config.className}
|
||||
${sizeClasses[size]}
|
||||
`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
167
src/features/billing-usage/components/PlanCard.tsx
Normal file
167
src/features/billing-usage/components/PlanCard.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import type { SubscriptionPlan } from '../types';
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: SubscriptionPlan;
|
||||
isSelected?: boolean;
|
||||
isCurrent?: boolean;
|
||||
billingCycle?: 'monthly' | 'annual';
|
||||
onSelect?: (plan: SubscriptionPlan) => void;
|
||||
showFeatures?: boolean;
|
||||
}
|
||||
|
||||
export const PlanCard: React.FC<PlanCardProps> = ({
|
||||
plan,
|
||||
isSelected = false,
|
||||
isCurrent = false,
|
||||
billingCycle = 'monthly',
|
||||
onSelect,
|
||||
showFeatures = true,
|
||||
}) => {
|
||||
const price = billingCycle === 'annual' && plan.baseAnnualPrice
|
||||
? plan.baseAnnualPrice / 12
|
||||
: plan.baseMonthlyPrice;
|
||||
|
||||
const annualSavings = plan.baseAnnualPrice
|
||||
? ((plan.baseMonthlyPrice * 12 - plan.baseAnnualPrice) / (plan.baseMonthlyPrice * 12)) * 100
|
||||
: 0;
|
||||
|
||||
const formatPrice = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const enabledFeatures = Object.entries(plan.features)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative rounded-lg border-2 p-6 transition-all
|
||||
${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'}
|
||||
${isCurrent ? 'ring-2 ring-green-500' : ''}
|
||||
${onSelect ? 'cursor-pointer hover:border-blue-300' : ''}
|
||||
`}
|
||||
onClick={() => onSelect?.(plan)}
|
||||
>
|
||||
{isCurrent && (
|
||||
<span className="absolute -top-3 left-4 rounded-full bg-green-500 px-3 py-1 text-xs font-medium text-white">
|
||||
Plan actual
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
||||
{plan.description && (
|
||||
<p className="mt-1 text-sm text-gray-500">{plan.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-4xl font-bold text-gray-900">{formatPrice(price)}</span>
|
||||
<span className="ml-1 text-gray-500">/mes</span>
|
||||
</div>
|
||||
{billingCycle === 'annual' && annualSavings > 0 && (
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
Ahorra {annualSavings.toFixed(0)}% con facturación anual
|
||||
</p>
|
||||
)}
|
||||
{plan.setupFee > 0 && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
+ {formatPrice(plan.setupFee)} setup único
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-3 border-t border-gray-200 pt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Usuarios</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{plan.maxUsers === -1 ? 'Ilimitados' : `Hasta ${plan.maxUsers}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Sucursales</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{plan.maxBranches === -1 ? 'Ilimitadas' : `Hasta ${plan.maxBranches}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Almacenamiento</span>
|
||||
<span className="font-medium text-gray-900">{plan.storageGb} GB</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">API calls/mes</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{plan.apiCallsMonthly === -1 ? 'Ilimitadas' : plan.apiCallsMonthly.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeatures && enabledFeatures.length > 0 && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<p className="mb-2 text-sm font-medium text-gray-900">Incluye:</p>
|
||||
<ul className="space-y-2">
|
||||
{enabledFeatures.slice(0, 5).map((feature) => (
|
||||
<li key={feature} className="flex items-center text-sm text-gray-600">
|
||||
<svg className="mr-2 h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{feature.replace(/_/g, ' ')}
|
||||
</li>
|
||||
))}
|
||||
{enabledFeatures.length > 5 && (
|
||||
<li className="text-sm text-gray-500">
|
||||
+{enabledFeatures.length - 5} más
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.includedModules.length > 0 && (
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<p className="mb-2 text-sm font-medium text-gray-900">Módulos:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plan.includedModules.map((module) => (
|
||||
<span
|
||||
key={module}
|
||||
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{module}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSelect && (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
mt-6 w-full rounded-lg py-2 text-sm font-medium transition-colors
|
||||
${isSelected
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}
|
||||
`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(plan);
|
||||
}}
|
||||
>
|
||||
{isSelected ? 'Seleccionado' : 'Seleccionar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
src/features/billing-usage/components/PlanSelector.tsx
Normal file
90
src/features/billing-usage/components/PlanSelector.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PlanCard } from './PlanCard';
|
||||
import type { SubscriptionPlan, BillingCycle } from '../types';
|
||||
|
||||
interface PlanSelectorProps {
|
||||
plans: SubscriptionPlan[];
|
||||
selectedPlanId?: string;
|
||||
currentPlanId?: string;
|
||||
onSelect: (plan: SubscriptionPlan, billingCycle: BillingCycle) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const PlanSelector: React.FC<PlanSelectorProps> = ({
|
||||
plans,
|
||||
selectedPlanId,
|
||||
currentPlanId,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
|
||||
|
||||
const activePlans = plans.filter((plan) => plan.isActive && plan.isPublic);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activePlans.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-50 p-8 text-center">
|
||||
<p className="text-gray-500">No hay planes disponibles</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex justify-center">
|
||||
<div className="inline-flex rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
${billingCycle === 'monthly'
|
||||
? 'bg-white text-gray-900 shadow'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}
|
||||
`}
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
>
|
||||
Mensual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
${billingCycle === 'annual'
|
||||
? 'bg-white text-gray-900 shadow'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}
|
||||
`}
|
||||
onClick={() => setBillingCycle('annual')}
|
||||
>
|
||||
Anual
|
||||
<span className="ml-1 rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">
|
||||
Ahorra
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{activePlans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isSelected={plan.id === selectedPlanId}
|
||||
isCurrent={plan.id === currentPlanId}
|
||||
billingCycle={billingCycle}
|
||||
onSelect={(p) => onSelect(p, billingCycle)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import type { SubscriptionStatus } from '../types';
|
||||
|
||||
interface SubscriptionStatusBadgeProps {
|
||||
status: SubscriptionStatus;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
|
||||
trial: {
|
||||
label: 'Prueba',
|
||||
className: 'bg-purple-100 text-purple-700',
|
||||
},
|
||||
active: {
|
||||
label: 'Activa',
|
||||
className: 'bg-green-100 text-green-700',
|
||||
},
|
||||
past_due: {
|
||||
label: 'Pago pendiente',
|
||||
className: 'bg-yellow-100 text-yellow-700',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelada',
|
||||
className: 'bg-red-100 text-red-700',
|
||||
},
|
||||
suspended: {
|
||||
label: 'Suspendida',
|
||||
className: 'bg-gray-100 text-gray-700',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-sm',
|
||||
lg: 'px-3 py-1.5 text-base',
|
||||
};
|
||||
|
||||
export const SubscriptionStatusBadge: React.FC<SubscriptionStatusBadgeProps> = ({
|
||||
status,
|
||||
size = 'md',
|
||||
}) => {
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full font-medium
|
||||
${config.className}
|
||||
${sizeClasses[size]}
|
||||
`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
72
src/features/billing-usage/components/UsageProgressBar.tsx
Normal file
72
src/features/billing-usage/components/UsageProgressBar.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
|
||||
interface UsageProgressBarProps {
|
||||
label: string;
|
||||
current: number;
|
||||
limit: number;
|
||||
unit?: string;
|
||||
showOverage?: boolean;
|
||||
}
|
||||
|
||||
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
|
||||
label,
|
||||
current,
|
||||
limit,
|
||||
unit = '',
|
||||
showOverage = true,
|
||||
}) => {
|
||||
const isUnlimited = limit === -1;
|
||||
const percent = isUnlimited ? 0 : Math.min((current / limit) * 100, 100);
|
||||
const overage = !isUnlimited && current > limit ? current - limit : 0;
|
||||
|
||||
const getColorClass = () => {
|
||||
if (isUnlimited) return 'bg-gray-400';
|
||||
if (percent >= 100) return 'bg-red-500';
|
||||
if (percent >= 80) return 'bg-yellow-500';
|
||||
return 'bg-blue-500';
|
||||
};
|
||||
|
||||
const formatValue = (value: number) => {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-gray-700">{label}</span>
|
||||
<span className="text-gray-500">
|
||||
{formatValue(current)}
|
||||
{unit && ` ${unit}`}
|
||||
{!isUnlimited && (
|
||||
<>
|
||||
{' / '}
|
||||
{formatValue(limit)}
|
||||
{unit && ` ${unit}`}
|
||||
</>
|
||||
)}
|
||||
{isUnlimited && ' (ilimitado)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${getColorClass()}`}
|
||||
style={{ width: isUnlimited ? '0%' : `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">
|
||||
{isUnlimited ? 'Sin límite' : `${percent.toFixed(1)}% usado`}
|
||||
</span>
|
||||
{showOverage && overage > 0 && (
|
||||
<span className="font-medium text-red-600">
|
||||
+{formatValue(overage)} excedente
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/features/billing-usage/components/UsageSummaryCard.tsx
Normal file
102
src/features/billing-usage/components/UsageSummaryCard.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { UsageProgressBar } from './UsageProgressBar';
|
||||
import type { UsageSummary } from '../types';
|
||||
|
||||
interface UsageSummaryCardProps {
|
||||
summary: UsageSummary | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const UsageSummaryCard: React.FC<UsageSummaryCardProps> = ({
|
||||
summary,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<p className="text-center text-gray-500">No hay datos de uso disponibles</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasOverages =
|
||||
summary.overages.users > 0 ||
|
||||
summary.overages.branches > 0 ||
|
||||
summary.overages.storage > 0 ||
|
||||
summary.overages.apiCalls > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Uso del Plan</h3>
|
||||
{summary.currentPeriod && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Período: {new Date(summary.currentPeriod.periodStart).toLocaleDateString('es-MX')} -{' '}
|
||||
{new Date(summary.currentPeriod.periodEnd).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<UsageProgressBar
|
||||
label="Usuarios activos"
|
||||
current={summary.currentPeriod?.activeUsers || 0}
|
||||
limit={summary.limits.maxUsers}
|
||||
/>
|
||||
|
||||
<UsageProgressBar
|
||||
label="Sucursales"
|
||||
current={summary.currentPeriod?.activeBranches || 0}
|
||||
limit={summary.limits.maxBranches}
|
||||
/>
|
||||
|
||||
<UsageProgressBar
|
||||
label="Almacenamiento"
|
||||
current={summary.currentPeriod?.storageUsedGb || 0}
|
||||
limit={summary.limits.storageGb}
|
||||
unit="GB"
|
||||
/>
|
||||
|
||||
<UsageProgressBar
|
||||
label="API calls"
|
||||
current={summary.currentPeriod?.apiCalls || 0}
|
||||
limit={summary.limits.apiCallsMonthly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasOverages && (
|
||||
<div className="border-t border-gray-200 bg-red-50 px-6 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-red-800">Has excedido los límites de tu plan</p>
|
||||
<p className="mt-1 text-sm text-red-700">
|
||||
Los excedentes se facturarán en tu próximo período de facturación.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
src/features/billing-usage/components/index.ts
Normal file
8
src/features/billing-usage/components/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { PlanCard } from './PlanCard';
|
||||
export { PlanSelector } from './PlanSelector';
|
||||
export { SubscriptionStatusBadge } from './SubscriptionStatusBadge';
|
||||
export { InvoiceStatusBadge } from './InvoiceStatusBadge';
|
||||
export { InvoiceList } from './InvoiceList';
|
||||
export { UsageProgressBar } from './UsageProgressBar';
|
||||
export { UsageSummaryCard } from './UsageSummaryCard';
|
||||
export { CouponInput } from './CouponInput';
|
||||
12
src/features/billing-usage/hooks/index.ts
Normal file
12
src/features/billing-usage/hooks/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export { usePlans, usePlan, usePublicPlans } from './usePlans';
|
||||
export { useSubscription, useSubscriptionById } from './useSubscription';
|
||||
export { useInvoices, useInvoice, useUpcomingInvoice } from './useInvoices';
|
||||
export {
|
||||
useUsageHistory,
|
||||
useCurrentUsage,
|
||||
useUsageSummary,
|
||||
useUsageTrend,
|
||||
useLimitCheck,
|
||||
useOverages,
|
||||
} from './useUsage';
|
||||
export { useCoupons, useCoupon, useCouponValidation, useCouponStats } from './useCoupons';
|
||||
192
src/features/billing-usage/hooks/useCoupons.ts
Normal file
192
src/features/billing-usage/hooks/useCoupons.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { couponsApi } from '../api';
|
||||
import type {
|
||||
Coupon,
|
||||
CouponFilters,
|
||||
CreateCouponDto,
|
||||
UpdateCouponDto,
|
||||
CouponValidation,
|
||||
CouponStats,
|
||||
} from '../types';
|
||||
|
||||
interface UseCouponsState {
|
||||
coupons: Coupon[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseCouponsReturn extends UseCouponsState {
|
||||
filters: CouponFilters;
|
||||
setFilters: (filters: CouponFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
createCoupon: (data: CreateCouponDto) => Promise<Coupon>;
|
||||
updateCoupon: (id: string, data: UpdateCouponDto) => Promise<Coupon>;
|
||||
deleteCoupon: (id: string) => Promise<void>;
|
||||
activateCoupon: (id: string) => Promise<void>;
|
||||
deactivateCoupon: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCoupons(initialFilters?: CouponFilters): UseCouponsReturn {
|
||||
const [state, setState] = useState<UseCouponsState>({
|
||||
coupons: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<CouponFilters>(initialFilters || { page: 1, limit: 10 });
|
||||
|
||||
const fetchCoupons = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await couponsApi.getAll(filters);
|
||||
setState({
|
||||
coupons: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar cupones',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoupons();
|
||||
}, [fetchCoupons]);
|
||||
|
||||
const createCoupon = async (data: CreateCouponDto): Promise<Coupon> => {
|
||||
const coupon = await couponsApi.create(data);
|
||||
await fetchCoupons();
|
||||
return coupon;
|
||||
};
|
||||
|
||||
const updateCoupon = async (id: string, data: UpdateCouponDto): Promise<Coupon> => {
|
||||
const coupon = await couponsApi.update(id, data);
|
||||
await fetchCoupons();
|
||||
return coupon;
|
||||
};
|
||||
|
||||
const deleteCoupon = async (id: string): Promise<void> => {
|
||||
await couponsApi.delete(id);
|
||||
await fetchCoupons();
|
||||
};
|
||||
|
||||
const activateCoupon = async (id: string): Promise<void> => {
|
||||
await couponsApi.activate(id);
|
||||
await fetchCoupons();
|
||||
};
|
||||
|
||||
const deactivateCoupon = async (id: string): Promise<void> => {
|
||||
await couponsApi.deactivate(id);
|
||||
await fetchCoupons();
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchCoupons,
|
||||
createCoupon,
|
||||
updateCoupon,
|
||||
deleteCoupon,
|
||||
activateCoupon,
|
||||
deactivateCoupon,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCoupon(id: string | undefined) {
|
||||
const [coupon, setCoupon] = useState<Coupon | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCoupon = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await couponsApi.getById(id);
|
||||
setCoupon(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar cupón');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoupon();
|
||||
}, [fetchCoupon]);
|
||||
|
||||
return { coupon, isLoading, error, refresh: fetchCoupon };
|
||||
}
|
||||
|
||||
export function useCouponValidation() {
|
||||
const [validation, setValidation] = useState<CouponValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const validateCoupon = useCallback(async (code: string, planId?: string, amount?: number) => {
|
||||
setIsValidating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await couponsApi.validate(code, planId, amount);
|
||||
setValidation(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al validar cupón';
|
||||
setError(message);
|
||||
setValidation({ isValid: false, message, errors: [message] });
|
||||
return null;
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearValidation = useCallback(() => {
|
||||
setValidation(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { validation, isValidating, error, validateCoupon, clearValidation };
|
||||
}
|
||||
|
||||
export function useCouponStats() {
|
||||
const [stats, setStats] = useState<CouponStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await couponsApi.getStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar estadísticas');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
return { stats, isLoading, error, refresh: fetchStats };
|
||||
}
|
||||
140
src/features/billing-usage/hooks/useInvoices.ts
Normal file
140
src/features/billing-usage/hooks/useInvoices.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { invoicesApi } from '../api';
|
||||
import type { Invoice, InvoiceFilters } from '../types';
|
||||
|
||||
interface UseInvoicesState {
|
||||
invoices: Invoice[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseInvoicesReturn extends UseInvoicesState {
|
||||
filters: InvoiceFilters;
|
||||
setFilters: (filters: InvoiceFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
downloadInvoice: (id: string) => Promise<void>;
|
||||
sendInvoiceByEmail: (id: string, email?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useInvoices(initialFilters?: InvoiceFilters): UseInvoicesReturn {
|
||||
const [state, setState] = useState<UseInvoicesState>({
|
||||
invoices: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<InvoiceFilters>(
|
||||
initialFilters || { page: 1, limit: 10, invoiceContext: 'saas' }
|
||||
);
|
||||
|
||||
const fetchInvoices = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await invoicesApi.getAll(filters);
|
||||
setState({
|
||||
invoices: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar facturas',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices();
|
||||
}, [fetchInvoices]);
|
||||
|
||||
const downloadInvoice = async (id: string): Promise<void> => {
|
||||
const blob = await invoicesApi.download(id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `invoice-${id}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const sendInvoiceByEmail = async (id: string, email?: string): Promise<void> => {
|
||||
await invoicesApi.sendByEmail(id, email);
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchInvoices,
|
||||
downloadInvoice,
|
||||
sendInvoiceByEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInvoice(id: string | undefined) {
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchInvoice = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await invoicesApi.getById(id);
|
||||
setInvoice(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar factura');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice();
|
||||
}, [fetchInvoice]);
|
||||
|
||||
return { invoice, isLoading, error, refresh: fetchInvoice };
|
||||
}
|
||||
|
||||
export function useUpcomingInvoice() {
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchInvoice = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await invoicesApi.getUpcoming();
|
||||
setInvoice(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar próxima factura');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice();
|
||||
}, [fetchInvoice]);
|
||||
|
||||
return { invoice, isLoading, error, refresh: fetchInvoice };
|
||||
}
|
||||
155
src/features/billing-usage/hooks/usePlans.ts
Normal file
155
src/features/billing-usage/hooks/usePlans.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { plansApi } from '../api';
|
||||
import type { SubscriptionPlan, PlanFilters, CreatePlanDto, UpdatePlanDto } from '../types';
|
||||
|
||||
interface UsePlansState {
|
||||
plans: SubscriptionPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UsePlansReturn extends UsePlansState {
|
||||
filters: PlanFilters;
|
||||
setFilters: (filters: PlanFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
createPlan: (data: CreatePlanDto) => Promise<SubscriptionPlan>;
|
||||
updatePlan: (id: string, data: UpdatePlanDto) => Promise<SubscriptionPlan>;
|
||||
deletePlan: (id: string) => Promise<void>;
|
||||
activatePlan: (id: string) => Promise<void>;
|
||||
deactivatePlan: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePlans(initialFilters?: PlanFilters): UsePlansReturn {
|
||||
const [state, setState] = useState<UsePlansState>({
|
||||
plans: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<PlanFilters>(initialFilters || { page: 1, limit: 10 });
|
||||
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await plansApi.getAll(filters);
|
||||
setState({
|
||||
plans: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar planes',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [fetchPlans]);
|
||||
|
||||
const createPlan = async (data: CreatePlanDto): Promise<SubscriptionPlan> => {
|
||||
const plan = await plansApi.create(data);
|
||||
await fetchPlans();
|
||||
return plan;
|
||||
};
|
||||
|
||||
const updatePlan = async (id: string, data: UpdatePlanDto): Promise<SubscriptionPlan> => {
|
||||
const plan = await plansApi.update(id, data);
|
||||
await fetchPlans();
|
||||
return plan;
|
||||
};
|
||||
|
||||
const deletePlan = async (id: string): Promise<void> => {
|
||||
await plansApi.delete(id);
|
||||
await fetchPlans();
|
||||
};
|
||||
|
||||
const activatePlan = async (id: string): Promise<void> => {
|
||||
await plansApi.activate(id);
|
||||
await fetchPlans();
|
||||
};
|
||||
|
||||
const deactivatePlan = async (id: string): Promise<void> => {
|
||||
await plansApi.deactivate(id);
|
||||
await fetchPlans();
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchPlans,
|
||||
createPlan,
|
||||
updatePlan,
|
||||
deletePlan,
|
||||
activatePlan,
|
||||
deactivatePlan,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePlan(id: string | undefined) {
|
||||
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPlan = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await plansApi.getById(id);
|
||||
setPlan(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar plan');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan();
|
||||
}, [fetchPlan]);
|
||||
|
||||
return { plan, isLoading, error, refresh: fetchPlan };
|
||||
}
|
||||
|
||||
export function usePublicPlans() {
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPlans = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await plansApi.getPublic();
|
||||
setPlans(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar planes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [fetchPlans]);
|
||||
|
||||
return { plans, isLoading, error, refresh: fetchPlans };
|
||||
}
|
||||
151
src/features/billing-usage/hooks/useSubscription.ts
Normal file
151
src/features/billing-usage/hooks/useSubscription.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { subscriptionsApi } from '../api';
|
||||
import type {
|
||||
TenantSubscription,
|
||||
UpdateSubscriptionDto,
|
||||
CancelSubscriptionDto,
|
||||
BillingCycle,
|
||||
} from '../types';
|
||||
|
||||
interface UseSubscriptionState {
|
||||
subscription: TenantSubscription | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseSubscriptionReturn extends UseSubscriptionState {
|
||||
refresh: () => Promise<void>;
|
||||
updateSubscription: (data: UpdateSubscriptionDto) => Promise<TenantSubscription>;
|
||||
cancelSubscription: (data?: CancelSubscriptionDto) => Promise<void>;
|
||||
reactivateSubscription: () => Promise<void>;
|
||||
changePlan: (planId: string, billingCycle?: BillingCycle) => Promise<void>;
|
||||
applyCoupon: (couponCode: string) => Promise<void>;
|
||||
updateBillingInfo: (
|
||||
data: Pick<UpdateSubscriptionDto, 'billingEmail' | 'billingName' | 'billingAddress' | 'taxId'>
|
||||
) => Promise<void>;
|
||||
updatePaymentMethod: (paymentMethodId: string, paymentProvider: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSubscription(): UseSubscriptionReturn {
|
||||
const [state, setState] = useState<UseSubscriptionState>({
|
||||
subscription: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchSubscription = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const data = await subscriptionsApi.getCurrent();
|
||||
setState({
|
||||
subscription: data,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState({
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar suscripción',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscription();
|
||||
}, [fetchSubscription]);
|
||||
|
||||
const updateSubscription = async (data: UpdateSubscriptionDto): Promise<TenantSubscription> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.update(state.subscription.id, data);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
return updated;
|
||||
};
|
||||
|
||||
const cancelSubscription = async (data?: CancelSubscriptionDto): Promise<void> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.cancel(state.subscription.id, data);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
};
|
||||
|
||||
const reactivateSubscription = async (): Promise<void> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.reactivate(state.subscription.id);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
};
|
||||
|
||||
const changePlan = async (planId: string, billingCycle?: BillingCycle): Promise<void> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.changePlan(state.subscription.id, planId, billingCycle);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
};
|
||||
|
||||
const applyCoupon = async (couponCode: string): Promise<void> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.applyCoupon(state.subscription.id, couponCode);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
};
|
||||
|
||||
const updateBillingInfo = async (
|
||||
data: Pick<UpdateSubscriptionDto, 'billingEmail' | 'billingName' | 'billingAddress' | 'taxId'>
|
||||
): Promise<void> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.updateBillingInfo(state.subscription.id, data);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
};
|
||||
|
||||
const updatePaymentMethod = async (
|
||||
paymentMethodId: string,
|
||||
paymentProvider: string
|
||||
): Promise<void> => {
|
||||
if (!state.subscription) throw new Error('No hay suscripción activa');
|
||||
const updated = await subscriptionsApi.updatePaymentMethod(
|
||||
state.subscription.id,
|
||||
paymentMethodId,
|
||||
paymentProvider
|
||||
);
|
||||
setState((prev) => ({ ...prev, subscription: updated }));
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
refresh: fetchSubscription,
|
||||
updateSubscription,
|
||||
cancelSubscription,
|
||||
reactivateSubscription,
|
||||
changePlan,
|
||||
applyCoupon,
|
||||
updateBillingInfo,
|
||||
updatePaymentMethod,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSubscriptionById(id: string | undefined) {
|
||||
const [subscription, setSubscription] = useState<TenantSubscription | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSubscription = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await subscriptionsApi.getById(id);
|
||||
setSubscription(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar suscripción');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscription();
|
||||
}, [fetchSubscription]);
|
||||
|
||||
return { subscription, isLoading, error, refresh: fetchSubscription };
|
||||
}
|
||||
199
src/features/billing-usage/hooks/useUsage.ts
Normal file
199
src/features/billing-usage/hooks/useUsage.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { usageApi } from '../api';
|
||||
import type { UsageTracking, UsageSummary, UsageFilters } from '../types';
|
||||
|
||||
interface UseUsageState {
|
||||
usage: UsageTracking[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseUsageReturn extends UseUsageState {
|
||||
filters: UsageFilters;
|
||||
setFilters: (filters: UsageFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useUsageHistory(initialFilters?: UsageFilters): UseUsageReturn {
|
||||
const [state, setState] = useState<UseUsageState>({
|
||||
usage: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<UsageFilters>(initialFilters || { page: 1, limit: 12 });
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await usageApi.getAll(filters);
|
||||
setState({
|
||||
usage: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar historial de uso',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsage();
|
||||
}, [fetchUsage]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchUsage,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCurrentUsage() {
|
||||
const [usage, setUsage] = useState<UsageTracking | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usageApi.getCurrent();
|
||||
setUsage(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar uso actual');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsage();
|
||||
}, [fetchUsage]);
|
||||
|
||||
return { usage, isLoading, error, refresh: fetchUsage };
|
||||
}
|
||||
|
||||
export function useUsageSummary() {
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usageApi.getSummary();
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar resumen de uso');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, [fetchSummary]);
|
||||
|
||||
return { summary, isLoading, error, refresh: fetchSummary };
|
||||
}
|
||||
|
||||
export function useUsageTrend(metric: 'users' | 'storage' | 'apiCalls' | 'sales', months: number = 6) {
|
||||
const [trend, setTrend] = useState<{ period: string; value: number }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTrend = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usageApi.getTrend(metric, months);
|
||||
setTrend(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar tendencia');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [metric, months]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrend();
|
||||
}, [fetchTrend]);
|
||||
|
||||
return { trend, isLoading, error, refresh: fetchTrend };
|
||||
}
|
||||
|
||||
export function useLimitCheck(limitKey: string) {
|
||||
const [limitStatus, setLimitStatus] = useState<{
|
||||
allowed: boolean;
|
||||
current: number;
|
||||
limit: number;
|
||||
percent: number;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkLimit = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usageApi.checkLimit(limitKey);
|
||||
setLimitStatus(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al verificar límite');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [limitKey]);
|
||||
|
||||
useEffect(() => {
|
||||
checkLimit();
|
||||
}, [checkLimit]);
|
||||
|
||||
return { limitStatus, isLoading, error, refresh: checkLimit };
|
||||
}
|
||||
|
||||
export function useOverages() {
|
||||
const [overages, setOverages] = useState<{
|
||||
users: number;
|
||||
branches: number;
|
||||
storage: number;
|
||||
apiCalls: number;
|
||||
totalAmount: number;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchOverages = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usageApi.getOverages();
|
||||
setOverages(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar excedentes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverages();
|
||||
}, [fetchOverages]);
|
||||
|
||||
return { overages, isLoading, error, refresh: fetchOverages };
|
||||
}
|
||||
98
src/features/billing-usage/types/coupon.types.ts
Normal file
98
src/features/billing-usage/types/coupon.types.ts
Normal file
@ -0,0 +1,98 @@
|
||||
export type DiscountType = 'percentage' | 'fixed';
|
||||
export type DurationPeriod = 'once' | 'forever' | 'months';
|
||||
|
||||
export interface Coupon {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
discountType: DiscountType;
|
||||
discountValue: number;
|
||||
currency: string;
|
||||
applicablePlans: string[];
|
||||
minAmount: number;
|
||||
durationPeriod: DurationPeriod;
|
||||
durationMonths?: number | null;
|
||||
maxRedemptions?: number | null;
|
||||
currentRedemptions: number;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CouponRedemption {
|
||||
id: string;
|
||||
couponId: string;
|
||||
subscriptionId: string;
|
||||
tenantId: string;
|
||||
discountApplied: number;
|
||||
appliedAt: string;
|
||||
expiresAt?: string | null;
|
||||
createdAt: string;
|
||||
coupon?: Coupon;
|
||||
}
|
||||
|
||||
export interface CreateCouponDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
discountType: DiscountType;
|
||||
discountValue: number;
|
||||
currency?: string;
|
||||
applicablePlans?: string[];
|
||||
minAmount?: number;
|
||||
durationPeriod?: DurationPeriod;
|
||||
durationMonths?: number;
|
||||
maxRedemptions?: number;
|
||||
validFrom?: string;
|
||||
validUntil?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCouponDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
applicablePlans?: string[];
|
||||
minAmount?: number;
|
||||
maxRedemptions?: number;
|
||||
validFrom?: string;
|
||||
validUntil?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CouponValidation {
|
||||
isValid: boolean;
|
||||
coupon?: Coupon;
|
||||
discountAmount?: number;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface CouponFilters {
|
||||
search?: string;
|
||||
discountType?: DiscountType;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface CouponsResponse {
|
||||
data: Coupon[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CouponStats {
|
||||
totalCoupons: number;
|
||||
activeCoupons: number;
|
||||
totalRedemptions: number;
|
||||
totalDiscountGiven: number;
|
||||
}
|
||||
5
src/features/billing-usage/types/index.ts
Normal file
5
src/features/billing-usage/types/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './plan.types';
|
||||
export * from './subscription.types';
|
||||
export * from './invoice.types';
|
||||
export * from './usage.types';
|
||||
export * from './coupon.types';
|
||||
113
src/features/billing-usage/types/invoice.types.ts
Normal file
113
src/features/billing-usage/types/invoice.types.ts
Normal file
@ -0,0 +1,113 @@
|
||||
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'cancelled' | 'overdue';
|
||||
export type InvoiceType = 'standard' | 'credit_note' | 'debit_note';
|
||||
export type InvoiceContext = 'commercial' | 'saas';
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
taxRate: number;
|
||||
taxAmount: number;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
productId?: string | null;
|
||||
productCode?: string | null;
|
||||
productName?: string | null;
|
||||
unitOfMeasure?: string | null;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId: string;
|
||||
invoiceNumber: string;
|
||||
invoiceType: InvoiceType;
|
||||
invoiceContext: InvoiceContext;
|
||||
status: InvoiceStatus;
|
||||
partnerId?: string | null;
|
||||
partnerName?: string | null;
|
||||
subscriptionId?: string | null;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
paymentDate?: string | null;
|
||||
currency: string;
|
||||
exchangeRate: number;
|
||||
subtotal: number;
|
||||
discountTotal: number;
|
||||
taxTotal: number;
|
||||
total: number;
|
||||
amountPaid: number;
|
||||
amountDue: number;
|
||||
notes?: string | null;
|
||||
internalNotes?: string | null;
|
||||
paymentTerms?: string | null;
|
||||
paymentMethodId?: string | null;
|
||||
billingAddress: Record<string, string>;
|
||||
cfdiUse?: string | null;
|
||||
cfdiUuid?: string | null;
|
||||
satStatus?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
cancellationReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
items?: InvoiceItem[];
|
||||
}
|
||||
|
||||
export interface CreateInvoiceDto {
|
||||
companyId: string;
|
||||
invoiceType?: InvoiceType;
|
||||
invoiceContext?: InvoiceContext;
|
||||
partnerId?: string;
|
||||
subscriptionId?: string;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
dueDate: string;
|
||||
currency?: string;
|
||||
notes?: string;
|
||||
internalNotes?: string;
|
||||
paymentTerms?: string;
|
||||
billingAddress?: Record<string, string>;
|
||||
cfdiUse?: string;
|
||||
items: CreateInvoiceItemDto[];
|
||||
}
|
||||
|
||||
export interface CreateInvoiceItemDto {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount?: number;
|
||||
taxRate?: number;
|
||||
productId?: string;
|
||||
unitOfMeasure?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceFilters {
|
||||
tenantId?: string;
|
||||
companyId?: string;
|
||||
partnerId?: string;
|
||||
subscriptionId?: string;
|
||||
status?: InvoiceStatus;
|
||||
invoiceType?: InvoiceType;
|
||||
invoiceContext?: InvoiceContext;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface InvoicesResponse {
|
||||
data: Invoice[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
81
src/features/billing-usage/types/plan.types.ts
Normal file
81
src/features/billing-usage/types/plan.types.ts
Normal file
@ -0,0 +1,81 @@
|
||||
export type PlanType = 'saas' | 'on_premise' | 'hybrid';
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
planType: PlanType;
|
||||
baseMonthlyPrice: number;
|
||||
baseAnnualPrice?: number | null;
|
||||
setupFee: number;
|
||||
maxUsers: number;
|
||||
maxBranches: number;
|
||||
storageGb: number;
|
||||
apiCallsMonthly: number;
|
||||
includedModules: string[];
|
||||
includedPlatforms: string[];
|
||||
features: Record<string, boolean>;
|
||||
isActive: boolean;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePlanDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
planType?: PlanType;
|
||||
baseMonthlyPrice: number;
|
||||
baseAnnualPrice?: number;
|
||||
setupFee?: number;
|
||||
maxUsers?: number;
|
||||
maxBranches?: number;
|
||||
storageGb?: number;
|
||||
apiCallsMonthly?: number;
|
||||
includedModules?: string[];
|
||||
includedPlatforms?: string[];
|
||||
features?: Record<string, boolean>;
|
||||
isActive?: boolean;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePlanDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
planType?: PlanType;
|
||||
baseMonthlyPrice?: number;
|
||||
baseAnnualPrice?: number;
|
||||
setupFee?: number;
|
||||
maxUsers?: number;
|
||||
maxBranches?: number;
|
||||
storageGb?: number;
|
||||
apiCallsMonthly?: number;
|
||||
includedModules?: string[];
|
||||
includedPlatforms?: string[];
|
||||
features?: Record<string, boolean>;
|
||||
isActive?: boolean;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface PlanFilters {
|
||||
search?: string;
|
||||
planType?: PlanType;
|
||||
isActive?: boolean;
|
||||
isPublic?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
data: SubscriptionPlan[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
103
src/features/billing-usage/types/subscription.types.ts
Normal file
103
src/features/billing-usage/types/subscription.types.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { SubscriptionPlan } from './plan.types';
|
||||
|
||||
export type BillingCycle = 'monthly' | 'annual';
|
||||
export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended';
|
||||
|
||||
export interface BillingAddress {
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface TenantSubscription {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
planId: string;
|
||||
billingCycle: BillingCycle;
|
||||
currentPeriodStart: string;
|
||||
currentPeriodEnd: string;
|
||||
status: SubscriptionStatus;
|
||||
trialStart?: string | null;
|
||||
trialEnd?: string | null;
|
||||
billingEmail?: string | null;
|
||||
billingName?: string | null;
|
||||
billingAddress: BillingAddress;
|
||||
taxId?: string | null;
|
||||
paymentMethodId?: string | null;
|
||||
paymentProvider?: string | null;
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
lastPaymentAt?: string | null;
|
||||
lastPaymentAmount?: number | null;
|
||||
currentPrice: number;
|
||||
discountPercent: number;
|
||||
discountReason?: string | null;
|
||||
contractedUsers?: number | null;
|
||||
contractedBranches?: number | null;
|
||||
autoRenew: boolean;
|
||||
nextInvoiceDate?: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
cancelledAt?: string | null;
|
||||
cancellationReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
plan?: SubscriptionPlan;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionDto {
|
||||
tenantId: string;
|
||||
planId: string;
|
||||
billingCycle?: BillingCycle;
|
||||
billingEmail?: string;
|
||||
billingName?: string;
|
||||
billingAddress?: BillingAddress;
|
||||
taxId?: string;
|
||||
paymentMethodId?: string;
|
||||
paymentProvider?: string;
|
||||
currentPrice?: number;
|
||||
contractedUsers?: number;
|
||||
contractedBranches?: number;
|
||||
couponCode?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionDto {
|
||||
planId?: string;
|
||||
billingCycle?: BillingCycle;
|
||||
billingEmail?: string;
|
||||
billingName?: string;
|
||||
billingAddress?: BillingAddress;
|
||||
taxId?: string;
|
||||
paymentMethodId?: string;
|
||||
paymentProvider?: string;
|
||||
autoRenew?: boolean;
|
||||
contractedUsers?: number;
|
||||
contractedBranches?: number;
|
||||
}
|
||||
|
||||
export interface CancelSubscriptionDto {
|
||||
cancelImmediately?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionFilters {
|
||||
tenantId?: string;
|
||||
planId?: string;
|
||||
status?: SubscriptionStatus;
|
||||
billingCycle?: BillingCycle;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface SubscriptionsResponse {
|
||||
data: TenantSubscription[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
66
src/features/billing-usage/types/usage.types.ts
Normal file
66
src/features/billing-usage/types/usage.types.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export interface UsageTracking {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
activeUsers: number;
|
||||
peakConcurrentUsers: number;
|
||||
usersByProfile: Record<string, number>;
|
||||
usersByPlatform: Record<string, number>;
|
||||
activeBranches: number;
|
||||
storageUsedGb: number;
|
||||
documentsCount: number;
|
||||
apiCalls: number;
|
||||
apiErrors: number;
|
||||
salesCount: number;
|
||||
salesAmount: number;
|
||||
invoicesGenerated: number;
|
||||
mobileSessions: number;
|
||||
offlineSyncs: number;
|
||||
paymentTransactions: number;
|
||||
totalBillableAmount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
currentPeriod: UsageTracking | null;
|
||||
limits: {
|
||||
maxUsers: number;
|
||||
maxBranches: number;
|
||||
storageGb: number;
|
||||
apiCallsMonthly: number;
|
||||
};
|
||||
usage: {
|
||||
usersPercent: number;
|
||||
branchesPercent: number;
|
||||
storagePercent: number;
|
||||
apiCallsPercent: number;
|
||||
};
|
||||
overages: {
|
||||
users: number;
|
||||
branches: number;
|
||||
storage: number;
|
||||
apiCalls: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageFilters {
|
||||
tenantId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface UsageResponse {
|
||||
data: UsageTracking[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
248
src/pages/billing/BillingPage.tsx
Normal file
248
src/pages/billing/BillingPage.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
useSubscription,
|
||||
useUsageSummary,
|
||||
useInvoices,
|
||||
useUpcomingInvoice,
|
||||
useCouponValidation,
|
||||
} from '@features/billing-usage/hooks';
|
||||
import {
|
||||
SubscriptionStatusBadge,
|
||||
UsageSummaryCard,
|
||||
InvoiceList,
|
||||
CouponInput,
|
||||
} from '@features/billing-usage/components';
|
||||
|
||||
export const BillingPage: React.FC = () => {
|
||||
const { subscription, isLoading: subscriptionLoading } = useSubscription();
|
||||
const { summary, isLoading: usageLoading } = useUsageSummary();
|
||||
const { invoices, isLoading: invoicesLoading, downloadInvoice } = useInvoices({ limit: 5 });
|
||||
const { invoice: upcomingInvoice } = useUpcomingInvoice();
|
||||
const { validateCoupon } = useCouponValidation();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Facturación y Uso</h1>
|
||||
<p className="text-gray-600">Gestiona tu suscripción, uso y facturas</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Current Subscription */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Suscripción Actual</h2>
|
||||
{subscription && <SubscriptionStatusBadge status={subscription.status} />}
|
||||
</div>
|
||||
|
||||
{subscription ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{subscription.plan?.name || 'Plan'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Facturación {subscription.billingCycle === 'annual' ? 'anual' : 'mensual'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(subscription.currentPrice)}
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
/{subscription.billingCycle === 'annual' ? 'año' : 'mes'}
|
||||
</span>
|
||||
</p>
|
||||
{subscription.discountPercent > 0 && (
|
||||
<p className="text-sm text-green-600">
|
||||
{subscription.discountPercent}% descuento aplicado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Período actual</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(subscription.currentPeriodStart)} -{' '}
|
||||
{formatDate(subscription.currentPeriodEnd)}
|
||||
</p>
|
||||
</div>
|
||||
{subscription.nextInvoiceDate && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Próxima factura</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(subscription.nextInvoiceDate)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<div className="rounded-lg bg-yellow-50 p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Tu suscripción se cancelará el{' '}
|
||||
{formatDate(subscription.currentPeriodEnd)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to="/billing/plans"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Cambiar Plan
|
||||
</Link>
|
||||
<Link
|
||||
to="/billing/subscription"
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Gestionar Suscripción
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">No tienes una suscripción activa</p>
|
||||
<Link
|
||||
to="/billing/plans"
|
||||
className="mt-4 inline-block rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Ver Planes
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Invoice */}
|
||||
{upcomingInvoice && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-6">
|
||||
<h3 className="mb-2 font-semibold text-blue-900">Próxima Factura</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-blue-700">
|
||||
Fecha estimada: {formatDate(upcomingInvoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-blue-900">
|
||||
{formatCurrency(upcomingInvoice.total)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Invoices */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Facturas Recientes</h2>
|
||||
<Link
|
||||
to="/billing/invoices"
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
<InvoiceList
|
||||
invoices={invoices}
|
||||
isLoading={invoicesLoading}
|
||||
onDownload={(inv) => downloadInvoice(inv.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Usage Summary */}
|
||||
<UsageSummaryCard summary={summary} isLoading={usageLoading} />
|
||||
|
||||
{/* Apply Coupon */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Aplicar Cupón</h3>
|
||||
<CouponInput onValidate={validateCoupon} />
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Acciones Rápidas</h3>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
to="/billing/usage"
|
||||
className="flex items-center gap-2 rounded-lg p-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
Ver historial de uso
|
||||
</Link>
|
||||
<Link
|
||||
to="/billing/payment-methods"
|
||||
className="flex items-center gap-2 rounded-lg p-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
Métodos de pago
|
||||
</Link>
|
||||
<Link
|
||||
to="/billing/billing-info"
|
||||
className="flex items-center gap-2 rounded-lg p-2 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
Datos de facturación
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
169
src/pages/billing/InvoicesPage.tsx
Normal file
169
src/pages/billing/InvoicesPage.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { useInvoices } from '@features/billing-usage/hooks';
|
||||
import { InvoiceList } from '@features/billing-usage/components';
|
||||
import type { InvoiceFilters, InvoiceStatus } from '@features/billing-usage/types';
|
||||
|
||||
export const InvoicesPage: React.FC = () => {
|
||||
const {
|
||||
invoices,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
downloadInvoice,
|
||||
} = useInvoices({ invoiceContext: 'saas' });
|
||||
|
||||
const handleStatusFilter = (status: InvoiceStatus | '') => {
|
||||
setFilters({
|
||||
...filters,
|
||||
status: status || undefined,
|
||||
page: 1,
|
||||
} as InvoiceFilters);
|
||||
};
|
||||
|
||||
const handleDateFilter = (startDate: string, endDate: string) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
page: newPage,
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Facturas</h1>
|
||||
<p className="text-gray-600">Historial de todas tus facturas de suscripción</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleStatusFilter(e.target.value as InvoiceStatus | '')}
|
||||
className="mt-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="draft">Borrador</option>
|
||||
<option value="issued">Emitida</option>
|
||||
<option value="paid">Pagada</option>
|
||||
<option value="overdue">Vencida</option>
|
||||
<option value="cancelled">Cancelada</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="startDate" className="block text-sm font-medium text-gray-700">
|
||||
Desde
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="startDate"
|
||||
value={filters.startDate || ''}
|
||||
onChange={(e) => handleDateFilter(e.target.value, filters.endDate || '')}
|
||||
className="mt-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700">
|
||||
Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="endDate"
|
||||
value={filters.endDate || ''}
|
||||
onChange={(e) => handleDateFilter(filters.startDate || '', e.target.value)}
|
||||
className="mt-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(filters.status || filters.startDate || filters.endDate) && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 text-sm text-blue-600 hover:text-blue-800"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
invoiceContext: 'saas',
|
||||
})
|
||||
}
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice List */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<InvoiceList
|
||||
invoices={invoices}
|
||||
isLoading={isLoading}
|
||||
onDownload={(inv) => downloadInvoice(inv.id)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Mostrando {(page - 1) * (filters.limit || 10) + 1} a{' '}
|
||||
{Math.min(page * (filters.limit || 10), total)} de {total} facturas
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 1}
|
||||
className="rounded border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-gray-600">
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === totalPages}
|
||||
className="rounded border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
132
src/pages/billing/PlansPage.tsx
Normal file
132
src/pages/billing/PlansPage.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePublicPlans, useSubscription } from '@features/billing-usage/hooks';
|
||||
import { PlanSelector } from '@features/billing-usage/components';
|
||||
import type { SubscriptionPlan, BillingCycle } from '@features/billing-usage/types';
|
||||
|
||||
export const PlansPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { plans, isLoading, error } = usePublicPlans();
|
||||
const { subscription } = useSubscription();
|
||||
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
||||
|
||||
const handleSelect = (plan: SubscriptionPlan, billingCycle: BillingCycle) => {
|
||||
setSelectedPlan(plan);
|
||||
setSelectedCycle(billingCycle);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!selectedPlan) return;
|
||||
navigate(`/billing/checkout?planId=${selectedPlan.id}&cycle=${selectedCycle}`);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Elige tu Plan</h1>
|
||||
<p className="mt-2 text-lg text-gray-600">
|
||||
Selecciona el plan que mejor se adapte a las necesidades de tu negocio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PlanSelector
|
||||
plans={plans}
|
||||
selectedPlanId={selectedPlan?.id}
|
||||
currentPlanId={subscription?.planId}
|
||||
onSelect={handleSelect}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{selectedPlan && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Plan seleccionado</p>
|
||||
<p className="text-xl font-bold text-gray-900">{selectedPlan.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Facturación {selectedCycle === 'annual' ? 'anual' : 'mensual'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-sm font-medium text-white hover:bg-blue-700"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{subscription ? 'Cambiar Plan' : 'Continuar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="mt-16">
|
||||
<h2 className="mb-6 text-center text-2xl font-bold text-gray-900">
|
||||
Preguntas Frecuentes
|
||||
</h2>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<details className="rounded-lg border border-gray-200 bg-white">
|
||||
<summary className="cursor-pointer px-6 py-4 font-medium text-gray-900">
|
||||
¿Puedo cambiar de plan en cualquier momento?
|
||||
</summary>
|
||||
<p className="px-6 pb-4 text-gray-600">
|
||||
Sí, puedes cambiar tu plan en cualquier momento. Si cambias a un plan superior, el
|
||||
cambio es inmediato y se te cobrará la diferencia proporcional. Si cambias a un plan
|
||||
inferior, el cambio se aplicará en tu próximo período de facturación.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-lg border border-gray-200 bg-white">
|
||||
<summary className="cursor-pointer px-6 py-4 font-medium text-gray-900">
|
||||
¿Qué sucede si excedo los límites de mi plan?
|
||||
</summary>
|
||||
<p className="px-6 pb-4 text-gray-600">
|
||||
Si excedes los límites de tu plan, el servicio continuará funcionando pero se te
|
||||
facturarán los excedentes según las tarifas vigentes en tu próxima factura.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-lg border border-gray-200 bg-white">
|
||||
<summary className="cursor-pointer px-6 py-4 font-medium text-gray-900">
|
||||
¿Puedo cancelar mi suscripción?
|
||||
</summary>
|
||||
<p className="px-6 pb-4 text-gray-600">
|
||||
Sí, puedes cancelar tu suscripción en cualquier momento. Tu acceso continuará hasta
|
||||
el final del período de facturación actual. No hay penalizaciones por cancelación.
|
||||
</p>
|
||||
</details>
|
||||
<details className="rounded-lg border border-gray-200 bg-white">
|
||||
<summary className="cursor-pointer px-6 py-4 font-medium text-gray-900">
|
||||
¿Qué métodos de pago aceptan?
|
||||
</summary>
|
||||
<p className="px-6 pb-4 text-gray-600">
|
||||
Aceptamos tarjetas de crédito y débito (Visa, Mastercard, American Express),
|
||||
transferencias bancarias y pagos a través de MercadoPago.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
225
src/pages/billing/UsagePage.tsx
Normal file
225
src/pages/billing/UsagePage.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import React from 'react';
|
||||
import { useUsageHistory, useUsageSummary, useOverages } from '@features/billing-usage/hooks';
|
||||
import { UsageSummaryCard } from '@features/billing-usage/components';
|
||||
|
||||
export const UsagePage: React.FC = () => {
|
||||
const { summary, isLoading: summaryLoading } = useUsageSummary();
|
||||
const { usage: history, isLoading: historyLoading } = useUsageHistory({ limit: 12 });
|
||||
const { overages, isLoading: overagesLoading } = useOverages();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Uso y Consumo</h1>
|
||||
<p className="text-gray-600">Monitorea el uso de tu suscripción</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Current Usage Summary */}
|
||||
<div className="lg:col-span-2">
|
||||
<UsageSummaryCard summary={summary} isLoading={summaryLoading} />
|
||||
</div>
|
||||
|
||||
{/* Overages */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Excedentes del Período</h3>
|
||||
|
||||
{overagesLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : overages ? (
|
||||
<div className="space-y-4">
|
||||
{overages.users > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Usuarios adicionales</span>
|
||||
<span className="font-medium text-red-600">+{overages.users}</span>
|
||||
</div>
|
||||
)}
|
||||
{overages.branches > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Sucursales adicionales</span>
|
||||
<span className="font-medium text-red-600">+{overages.branches}</span>
|
||||
</div>
|
||||
)}
|
||||
{overages.storage > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Almacenamiento extra</span>
|
||||
<span className="font-medium text-red-600">+{overages.storage} GB</span>
|
||||
</div>
|
||||
)}
|
||||
{overages.apiCalls > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">API calls extra</span>
|
||||
<span className="font-medium text-red-600">
|
||||
+{overages.apiCalls.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overages.totalAmount > 0 ? (
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">Costo estimado</span>
|
||||
<span className="text-lg font-bold text-red-600">
|
||||
{formatCurrency(overages.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Se añadirá a tu próxima factura
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<svg
|
||||
className="mx-auto h-8 w-8 text-green-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-green-700">Sin excedentes este período</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-gray-500">No hay datos disponibles</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage History */}
|
||||
<div className="mt-8 rounded-lg border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Historial de Uso</h2>
|
||||
</div>
|
||||
|
||||
{historyLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : history.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
Período
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase text-gray-500">
|
||||
Usuarios
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase text-gray-500">
|
||||
Sucursales
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase text-gray-500">
|
||||
Storage
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase text-gray-500">
|
||||
API Calls
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium uppercase text-gray-500">
|
||||
Ventas
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase text-gray-500">
|
||||
Facturable
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{history.map((record) => (
|
||||
<tr key={record.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{formatDate(record.periodStart)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-center text-sm text-gray-600">
|
||||
{record.activeUsers}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-center text-sm text-gray-600">
|
||||
{record.activeBranches}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-center text-sm text-gray-600">
|
||||
{record.storageUsedGb.toFixed(1)} GB
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-center text-sm text-gray-600">
|
||||
{record.apiCalls.toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-center text-sm text-gray-600">
|
||||
{record.salesCount.toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-medium text-gray-900">
|
||||
{formatCurrency(record.totalBillableAmount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500">No hay historial de uso disponible</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Details */}
|
||||
{summary?.currentPeriod && (
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
{/* By Profile */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Usuarios por Perfil</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(summary.currentPeriod.usersByProfile).length > 0 ? (
|
||||
Object.entries(summary.currentPeriod.usersByProfile).map(([profile, count]) => (
|
||||
<div key={profile} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{profile}</span>
|
||||
<span className="font-medium text-gray-900">{count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sin datos</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Platform */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Usuarios por Plataforma</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(summary.currentPeriod.usersByPlatform).length > 0 ? (
|
||||
Object.entries(summary.currentPeriod.usersByPlatform).map(([platform, count]) => (
|
||||
<div key={platform} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 capitalize">{platform}</span>
|
||||
<span className="font-medium text-gray-900">{count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sin datos</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
src/pages/billing/index.ts
Normal file
4
src/pages/billing/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { BillingPage } from './BillingPage';
|
||||
export { PlansPage } from './PlansPage';
|
||||
export { InvoicesPage } from './InvoicesPage';
|
||||
export { UsagePage } from './UsagePage';
|
||||
Loading…
Reference in New Issue
Block a user