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:
rckrdmrd 2026-01-18 08:23:59 -06:00
parent b996680777
commit 28b27565f8
33 changed files with 3490 additions and 0 deletions

View File

@ -29,6 +29,12 @@ const PartnerDetailPage = lazy(() => import('@pages/partners/PartnerDetailPage')
const PartnerCreatePage = lazy(() => import('@pages/partners/PartnerCreatePage')); const PartnerCreatePage = lazy(() => import('@pages/partners/PartnerCreatePage'));
const PartnerEditPage = lazy(() => import('@pages/partners/PartnerEditPage')); 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 }) { function LazyWrapper({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>; 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 // Error pages
{ {
path: '/unauthorized', path: '/unauthorized',

View 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;
},
};

View 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';

View 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;
},
};

View 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;
},
};

View 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;
},
};

View 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;
},
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -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>
);
};

View 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>
);
};

View 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>
);
};

View 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';

View 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';

View 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 };
}

View 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 };
}

View 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 };
}

View 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 };
}

View 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 };
}

View 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;
}

View 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';

View 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;
};
}

View 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;
};
}

View 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;
};
}

View 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;
};
}

View 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>
);
};

View 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>
);
};

View 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">
, 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">
, 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,4 @@
export { BillingPage } from './BillingPage';
export { PlansPage } from './PlansPage';
export { InvoicesPage } from './InvoicesPage';
export { UsagePage } from './UsagePage';