Compare commits

...

No commits in common. "aba79054558a86b21c3c19d641ea8f291e23c9df" and "3a461cb184d9f37fb804a315a74609ca4a9ca5b9" have entirely different histories.

178 changed files with 32422 additions and 27 deletions

38
.gitignore vendored
View File

@ -1,47 +1,41 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build
# Build output
dist/
build/
.next/
out/
# Environment
.env
.env.local
.env.*.local
.env.development
.env.test
.env.production
# TypeScript build info
*.tsbuildinfo
tsconfig.tsbuildinfo
tsconfig.node.tsbuildinfo
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# TypeScript
*.tsbuildinfo
tsconfig.tsbuildinfo
tsconfig.node.tsbuildinfo
# Testing
coverage/
.nyc_output/
# Misc
.cache/
.parcel-cache/
tmp/
temp/
*.local
.eslintcache

View File

@ -1,3 +0,0 @@
# erp-core-frontend-v2
Frontend de erp-core - Workspace V2

View File

@ -29,6 +29,26 @@ const PartnerDetailPage = lazy(() => import('@pages/partners/PartnerDetailPage')
const PartnerCreatePage = lazy(() => import('@pages/partners/PartnerCreatePage'));
const PartnerEditPage = lazy(() => import('@pages/partners/PartnerEditPage'));
// Billing pages
const BillingPage = lazy(() => import('@pages/billing/BillingPage').then(m => ({ default: m.BillingPage })));
const PlansPage = lazy(() => import('@pages/billing/PlansPage').then(m => ({ default: m.PlansPage })));
const InvoicesPage = lazy(() => import('@pages/billing/InvoicesPage').then(m => ({ default: m.InvoicesPage })));
const UsagePage = lazy(() => import('@pages/billing/UsagePage').then(m => ({ default: m.UsagePage })));
// Settings pages
const SettingsPage = lazy(() => import('@pages/settings/SettingsPage').then(m => ({ default: m.SettingsPage })));
const CompanySettingsPage = lazy(() => import('@pages/settings/CompanySettingsPage').then(m => ({ default: m.CompanySettingsPage })));
const UsersSettingsPage = lazy(() => import('@pages/settings/UsersSettingsPage').then(m => ({ default: m.UsersSettingsPage })));
const ProfileSettingsPage = lazy(() => import('@pages/settings/ProfileSettingsPage').then(m => ({ default: m.ProfileSettingsPage })));
const SecuritySettingsPage = lazy(() => import('@pages/settings/SecuritySettingsPage').then(m => ({ default: m.SecuritySettingsPage })));
const SystemSettingsPage = lazy(() => import('@pages/settings/SystemSettingsPage').then(m => ({ default: m.SystemSettingsPage })));
const AuditLogsPage = lazy(() => import('@pages/settings/AuditLogsPage').then(m => ({ default: m.AuditLogsPage })));
// CRM pages
const PipelineKanbanPage = lazy(() => import('@pages/crm/PipelineKanbanPage').then(m => ({ default: m.PipelineKanbanPage })));
const LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage })));
const OpportunitiesPage = lazy(() => import('@pages/crm/OpportunitiesPage').then(m => ({ default: m.OpportunitiesPage })));
function LazyWrapper({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
}
@ -224,11 +244,40 @@ export const router = createBrowserRouter([
</DashboardWrapper>
),
},
// CRM routes
{
path: '/crm',
element: <Navigate to="/crm/pipeline" replace />,
},
{
path: '/crm/pipeline',
element: (
<DashboardWrapper>
<PipelineKanbanPage />
</DashboardWrapper>
),
},
{
path: '/crm/leads',
element: (
<DashboardWrapper>
<LeadsPage />
</DashboardWrapper>
),
},
{
path: '/crm/opportunities',
element: (
<DashboardWrapper>
<OpportunitiesPage />
</DashboardWrapper>
),
},
{
path: '/crm/*',
element: (
<DashboardWrapper>
<div className="text-center text-gray-500">Módulo CRM - En desarrollo</div>
<div className="text-center text-gray-500">Seccion CRM - En desarrollo</div>
</DashboardWrapper>
),
},
@ -240,11 +289,110 @@ export const router = createBrowserRouter([
</DashboardWrapper>
),
},
// Settings routes
{
path: '/settings',
element: (
<DashboardWrapper>
<SettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/company',
element: (
<DashboardWrapper>
<CompanySettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/users',
element: (
<DashboardWrapper>
<UsersSettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/profile',
element: (
<DashboardWrapper>
<ProfileSettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/security',
element: (
<DashboardWrapper>
<SecuritySettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/security/audit-logs',
element: (
<DashboardWrapper>
<AuditLogsPage />
</DashboardWrapper>
),
},
{
path: '/settings/system',
element: (
<DashboardWrapper>
<SystemSettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/*',
element: (
<DashboardWrapper>
<div className="text-center text-gray-500">Configuración - En desarrollo</div>
<div className="text-center text-gray-500">Seccion de Configuracion - En desarrollo</div>
</DashboardWrapper>
),
},
// 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>
),
},

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,52 @@
import axios from 'axios';
import type { Country, State, CreateStateDto, UpdateStateDto } from '../types';
const API_BASE = '/api/core';
export const countriesApi = {
// Countries
getAll: async (): Promise<Country[]> => {
const response = await axios.get(`${API_BASE}/countries`);
return response.data.data;
},
getById: async (id: string): Promise<Country> => {
const response = await axios.get(`${API_BASE}/countries/${id}`);
return response.data.data;
},
// States
getStates: async (params?: { countryId?: string; countryCode?: string; active?: boolean }): Promise<State[]> => {
const response = await axios.get(`${API_BASE}/states`, { params });
return response.data.data;
},
getStatesByCountry: async (countryId: string): Promise<State[]> => {
const response = await axios.get(`${API_BASE}/countries/${countryId}/states`);
return response.data.data;
},
getStatesByCountryCode: async (countryCode: string): Promise<State[]> => {
const response = await axios.get(`${API_BASE}/countries/code/${countryCode}/states`);
return response.data.data;
},
getStateById: async (id: string): Promise<State> => {
const response = await axios.get(`${API_BASE}/states/${id}`);
return response.data.data;
},
createState: async (data: CreateStateDto): Promise<State> => {
const response = await axios.post(`${API_BASE}/states`, data);
return response.data.data;
},
updateState: async (id: string, data: UpdateStateDto): Promise<State> => {
const response = await axios.put(`${API_BASE}/states/${id}`, data);
return response.data.data;
},
deleteState: async (id: string): Promise<void> => {
await axios.delete(`${API_BASE}/states/${id}`);
},
};

View File

@ -0,0 +1,93 @@
import axios from 'axios';
import type {
Currency,
CreateCurrencyDto,
UpdateCurrencyDto,
CurrencyRate,
CreateCurrencyRateDto,
CurrencyConversion,
LatestRates,
} from '../types';
const API_BASE = '/api/core';
export const currenciesApi = {
// Currencies
getAll: async (activeOnly?: boolean): Promise<Currency[]> => {
const params = activeOnly ? { active: 'true' } : {};
const response = await axios.get(`${API_BASE}/currencies`, { params });
return response.data.data;
},
getById: async (id: string): Promise<Currency> => {
const response = await axios.get(`${API_BASE}/currencies/${id}`);
return response.data.data;
},
create: async (data: CreateCurrencyDto): Promise<Currency> => {
const response = await axios.post(`${API_BASE}/currencies`, data);
return response.data.data;
},
update: async (id: string, data: UpdateCurrencyDto): Promise<Currency> => {
const response = await axios.put(`${API_BASE}/currencies/${id}`, data);
return response.data.data;
},
// Currency Rates
getRates: async (params?: {
from?: string;
to?: string;
limit?: number;
}): Promise<CurrencyRate[]> => {
const response = await axios.get(`${API_BASE}/currency-rates`, { params });
return response.data.data;
},
getRate: async (from: string, to: string, date?: string): Promise<{
from: string;
to: string;
rate: number;
date: string;
}> => {
const params = date ? { date } : {};
const response = await axios.get(`${API_BASE}/currency-rates/rate/${from}/${to}`, { params });
return response.data.data;
},
getLatestRates: async (baseCurrency?: string): Promise<LatestRates> => {
const params = baseCurrency ? { base: baseCurrency } : {};
const response = await axios.get(`${API_BASE}/currency-rates/latest`, { params });
return response.data.data;
},
getRateHistory: async (from: string, to: string, days?: number): Promise<CurrencyRate[]> => {
const params = days ? { days } : {};
const response = await axios.get(`${API_BASE}/currency-rates/history/${from}/${to}`, { params });
return response.data.data;
},
createRate: async (data: CreateCurrencyRateDto): Promise<CurrencyRate> => {
const response = await axios.post(`${API_BASE}/currency-rates`, data);
return response.data.data;
},
convert: async (
amount: number,
fromCurrencyCode: string,
toCurrencyCode: string,
date?: string
): Promise<CurrencyConversion> => {
const response = await axios.post(`${API_BASE}/currency-rates/convert`, {
amount,
fromCurrencyCode,
toCurrencyCode,
date,
});
return response.data.data;
},
deleteRate: async (id: string): Promise<void> => {
await axios.delete(`${API_BASE}/currency-rates/${id}`);
},
};

View File

@ -0,0 +1,255 @@
import axios from 'axios';
import type {
TaxCategory,
FiscalRegime,
CfdiUse,
FiscalPaymentMethod,
FiscalPaymentType,
WithholdingType,
TaxNature,
PersonType,
} from '../types';
const API_BASE = '/api/v1/fiscal';
// ========== TAX CATEGORIES API ==========
export interface TaxCategoryFilter {
taxNature?: TaxNature;
active?: boolean;
}
export const taxCategoriesApi = {
getAll: async (filter?: TaxCategoryFilter): Promise<TaxCategory[]> => {
const params = new URLSearchParams();
if (filter?.taxNature) params.append('tax_nature', filter.taxNature);
if (filter?.active !== undefined) params.append('active', String(filter.active));
const response = await axios.get(`${API_BASE}/tax-categories`, { params });
return response.data.data;
},
getById: async (id: string): Promise<TaxCategory> => {
const response = await axios.get(`${API_BASE}/tax-categories/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<TaxCategory | null> => {
try {
const response = await axios.get(`${API_BASE}/tax-categories/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
getBySatCode: async (satCode: string): Promise<TaxCategory | null> => {
try {
const response = await axios.get(`${API_BASE}/tax-categories/by-sat-code/${satCode}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
};
// ========== FISCAL REGIMES API ==========
export interface FiscalRegimeFilter {
appliesTo?: PersonType;
active?: boolean;
}
export const fiscalRegimesApi = {
getAll: async (filter?: FiscalRegimeFilter): Promise<FiscalRegime[]> => {
const params = new URLSearchParams();
if (filter?.appliesTo) params.append('applies_to', filter.appliesTo);
if (filter?.active !== undefined) params.append('active', String(filter.active));
const response = await axios.get(`${API_BASE}/fiscal-regimes`, { params });
return response.data.data;
},
getById: async (id: string): Promise<FiscalRegime> => {
const response = await axios.get(`${API_BASE}/fiscal-regimes/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<FiscalRegime | null> => {
try {
const response = await axios.get(`${API_BASE}/fiscal-regimes/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
getForPersonType: async (personType: PersonType): Promise<FiscalRegime[]> => {
const response = await axios.get(`${API_BASE}/fiscal-regimes/person-type/${personType}`);
return response.data.data;
},
};
// ========== CFDI USES API ==========
export interface CfdiUseFilter {
appliesTo?: PersonType;
active?: boolean;
}
export const cfdiUsesApi = {
getAll: async (filter?: CfdiUseFilter): Promise<CfdiUse[]> => {
const params = new URLSearchParams();
if (filter?.appliesTo) params.append('applies_to', filter.appliesTo);
if (filter?.active !== undefined) params.append('active', String(filter.active));
const response = await axios.get(`${API_BASE}/cfdi-uses`, { params });
return response.data.data;
},
getById: async (id: string): Promise<CfdiUse> => {
const response = await axios.get(`${API_BASE}/cfdi-uses/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<CfdiUse | null> => {
try {
const response = await axios.get(`${API_BASE}/cfdi-uses/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
getForPersonType: async (personType: PersonType): Promise<CfdiUse[]> => {
const response = await axios.get(`${API_BASE}/cfdi-uses/person-type/${personType}`);
return response.data.data;
},
getForRegime: async (regimeCode: string): Promise<CfdiUse[]> => {
const response = await axios.get(`${API_BASE}/cfdi-uses/regime/${regimeCode}`);
return response.data.data;
},
};
// ========== PAYMENT METHODS API (SAT c_FormaPago) ==========
export interface PaymentMethodFilter {
requiresBankInfo?: boolean;
active?: boolean;
}
export const fiscalPaymentMethodsApi = {
getAll: async (filter?: PaymentMethodFilter): Promise<FiscalPaymentMethod[]> => {
const params = new URLSearchParams();
if (filter?.requiresBankInfo !== undefined) {
params.append('requires_bank_info', String(filter.requiresBankInfo));
}
if (filter?.active !== undefined) params.append('active', String(filter.active));
const response = await axios.get(`${API_BASE}/payment-methods`, { params });
return response.data.data;
},
getById: async (id: string): Promise<FiscalPaymentMethod> => {
const response = await axios.get(`${API_BASE}/payment-methods/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<FiscalPaymentMethod | null> => {
try {
const response = await axios.get(`${API_BASE}/payment-methods/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
};
// ========== PAYMENT TYPES API (SAT c_MetodoPago) ==========
export interface PaymentTypeFilter {
active?: boolean;
}
export const fiscalPaymentTypesApi = {
getAll: async (filter?: PaymentTypeFilter): Promise<FiscalPaymentType[]> => {
const params = new URLSearchParams();
if (filter?.active !== undefined) params.append('active', String(filter.active));
const response = await axios.get(`${API_BASE}/payment-types`, { params });
return response.data.data;
},
getById: async (id: string): Promise<FiscalPaymentType> => {
const response = await axios.get(`${API_BASE}/payment-types/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<FiscalPaymentType | null> => {
try {
const response = await axios.get(`${API_BASE}/payment-types/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
};
// ========== WITHHOLDING TYPES API ==========
export interface WithholdingTypeFilter {
taxCategoryId?: string;
active?: boolean;
}
export const withholdingTypesApi = {
getAll: async (filter?: WithholdingTypeFilter): Promise<WithholdingType[]> => {
const params = new URLSearchParams();
if (filter?.taxCategoryId) params.append('tax_category_id', filter.taxCategoryId);
if (filter?.active !== undefined) params.append('active', String(filter.active));
const response = await axios.get(`${API_BASE}/withholding-types`, { params });
return response.data.data;
},
getById: async (id: string): Promise<WithholdingType> => {
const response = await axios.get(`${API_BASE}/withholding-types/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<WithholdingType | null> => {
try {
const response = await axios.get(`${API_BASE}/withholding-types/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
getByCategory: async (categoryId: string): Promise<WithholdingType[]> => {
const response = await axios.get(`${API_BASE}/withholding-types/by-category/${categoryId}`);
return response.data.data;
},
};

View File

@ -0,0 +1,19 @@
export { countriesApi } from './countries.api';
export { currenciesApi } from './currencies.api';
export { uomApi } from './uom.api';
export {
taxCategoriesApi,
fiscalRegimesApi,
cfdiUsesApi,
fiscalPaymentMethodsApi,
fiscalPaymentTypesApi,
withholdingTypesApi,
} from './fiscal.api';
export type {
TaxCategoryFilter,
FiscalRegimeFilter,
CfdiUseFilter,
PaymentMethodFilter,
PaymentTypeFilter,
WithholdingTypeFilter,
} from './fiscal.api';

View File

@ -0,0 +1,81 @@
import axios from 'axios';
import type {
UomCategory,
Uom,
CreateUomDto,
UpdateUomDto,
UomConversion,
ConversionTable,
} from '../types';
const API_BASE = '/api/core';
export const uomApi = {
// UoM Categories
getCategories: async (activeOnly?: boolean): Promise<UomCategory[]> => {
const params = activeOnly ? { active: 'true' } : {};
const response = await axios.get(`${API_BASE}/uom-categories`, { params });
return response.data.data;
},
getCategoryById: async (id: string): Promise<UomCategory> => {
const response = await axios.get(`${API_BASE}/uom-categories/${id}`);
return response.data.data;
},
// UoM
getAll: async (params?: { categoryId?: string; active?: boolean }): Promise<Uom[]> => {
const queryParams: Record<string, string> = {};
if (params?.categoryId) queryParams.category_id = params.categoryId;
if (params?.active) queryParams.active = 'true';
const response = await axios.get(`${API_BASE}/uom`, { params: queryParams });
return response.data.data;
},
getById: async (id: string): Promise<Uom> => {
const response = await axios.get(`${API_BASE}/uom/${id}`);
return response.data.data;
},
getByCode: async (code: string): Promise<Uom | null> => {
try {
const response = await axios.get(`${API_BASE}/uom/by-code/${code}`);
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
create: async (data: CreateUomDto): Promise<Uom> => {
const response = await axios.post(`${API_BASE}/uom`, data);
return response.data.data;
},
update: async (id: string, data: UpdateUomDto): Promise<Uom> => {
const response = await axios.put(`${API_BASE}/uom/${id}`, data);
return response.data.data;
},
// Conversions
convert: async (
quantity: number,
fromUomId: string,
toUomId: string
): Promise<UomConversion> => {
const response = await axios.post(`${API_BASE}/uom/convert`, {
quantity,
fromUomId,
toUomId,
});
return response.data.data;
},
getConversionTable: async (categoryId: string): Promise<ConversionTable> => {
const response = await axios.get(`${API_BASE}/uom-categories/${categoryId}/conversions`);
return response.data.data;
},
};

View File

@ -0,0 +1,301 @@
import React, { useState, useCallback } from 'react';
import { CountrySelect } from './CountrySelect';
import { StateSelect } from './StateSelect';
import type { Country, State } from '../types';
export interface AddressData {
street?: string;
exteriorNumber?: string;
interiorNumber?: string;
neighborhood?: string;
city?: string;
postalCode?: string;
countryId?: string;
countryCode?: string;
stateId?: string;
stateCode?: string;
}
interface AddressInputProps {
value?: AddressData;
onChange: (address: AddressData) => void;
disabled?: boolean;
required?: boolean;
className?: string;
errors?: Partial<Record<keyof AddressData, string>>;
showLabels?: boolean;
compact?: boolean;
}
export const AddressInput: React.FC<AddressInputProps> = ({
value = {},
onChange,
disabled = false,
required = false,
className = '',
errors = {},
showLabels = true,
compact = false,
}) => {
const [selectedCountry, setSelectedCountry] = useState<Country | null>(null);
const handleFieldChange = useCallback(
(field: keyof AddressData, fieldValue: string | undefined) => {
onChange({ ...value, [field]: fieldValue });
},
[value, onChange]
);
const handleCountryChange = useCallback(
(country: Country | null) => {
setSelectedCountry(country);
onChange({
...value,
countryId: country?.id,
countryCode: country?.code,
stateId: undefined,
stateCode: undefined,
});
},
[value, onChange]
);
const handleStateChange = useCallback(
(state: State | null) => {
onChange({
...value,
stateId: state?.id,
stateCode: state?.code,
});
},
[value, onChange]
);
const inputClasses = `
w-full rounded-md border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const getInputClass = (field: keyof AddressData) => {
return `${inputClasses} ${errors[field] ? 'border-red-300' : 'border-gray-300'}`;
};
const renderLabel = (text: string, isRequired: boolean = false) => {
if (!showLabels) return null;
return (
<label className="mb-1 block text-sm font-medium text-gray-700">
{text}
{isRequired && <span className="ml-1 text-red-500">*</span>}
</label>
);
};
const renderError = (field: keyof AddressData) => {
if (!errors[field]) return null;
return <p className="mt-1 text-sm text-red-600">{errors[field]}</p>;
};
if (compact) {
return (
<div className={`space-y-3 ${className}`}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
{renderLabel('Calle', required)}
<input
type="text"
value={value.street || ''}
onChange={(e) => handleFieldChange('street', e.target.value)}
placeholder="Calle"
disabled={disabled}
className={getInputClass('street')}
/>
{renderError('street')}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
{renderLabel('No. Ext')}
<input
type="text"
value={value.exteriorNumber || ''}
onChange={(e) => handleFieldChange('exteriorNumber', e.target.value)}
placeholder="Ext"
disabled={disabled}
className={getInputClass('exteriorNumber')}
/>
</div>
<div>
{renderLabel('No. Int')}
<input
type="text"
value={value.interiorNumber || ''}
onChange={(e) => handleFieldChange('interiorNumber', e.target.value)}
placeholder="Int"
disabled={disabled}
className={getInputClass('interiorNumber')}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
{renderLabel('Colonia')}
<input
type="text"
value={value.neighborhood || ''}
onChange={(e) => handleFieldChange('neighborhood', e.target.value)}
placeholder="Colonia"
disabled={disabled}
className={getInputClass('neighborhood')}
/>
</div>
<div>
{renderLabel('Ciudad', required)}
<input
type="text"
value={value.city || ''}
onChange={(e) => handleFieldChange('city', e.target.value)}
placeholder="Ciudad"
disabled={disabled}
className={getInputClass('city')}
/>
{renderError('city')}
</div>
<div>
{renderLabel('C.P.')}
<input
type="text"
value={value.postalCode || ''}
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
placeholder="C.P."
disabled={disabled}
className={getInputClass('postalCode')}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CountrySelect
value={value.countryId}
onChange={handleCountryChange}
disabled={disabled}
required={required}
error={errors.countryId}
label={showLabels ? 'País' : undefined}
/>
<StateSelect
value={value.stateId}
onChange={handleStateChange}
countryId={selectedCountry?.id || value.countryId}
disabled={disabled}
error={errors.stateId}
label={showLabels ? 'Estado' : undefined}
/>
</div>
</div>
);
}
return (
<div className={`space-y-4 ${className}`}>
<div>
{renderLabel('Calle', required)}
<input
type="text"
value={value.street || ''}
onChange={(e) => handleFieldChange('street', e.target.value)}
placeholder="Nombre de la calle"
disabled={disabled}
className={getInputClass('street')}
/>
{renderError('street')}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
{renderLabel('Número Exterior')}
<input
type="text"
value={value.exteriorNumber || ''}
onChange={(e) => handleFieldChange('exteriorNumber', e.target.value)}
placeholder="No. Ext"
disabled={disabled}
className={getInputClass('exteriorNumber')}
/>
{renderError('exteriorNumber')}
</div>
<div>
{renderLabel('Número Interior')}
<input
type="text"
value={value.interiorNumber || ''}
onChange={(e) => handleFieldChange('interiorNumber', e.target.value)}
placeholder="No. Int (opcional)"
disabled={disabled}
className={getInputClass('interiorNumber')}
/>
{renderError('interiorNumber')}
</div>
<div>
{renderLabel('Código Postal')}
<input
type="text"
value={value.postalCode || ''}
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
placeholder="C.P."
disabled={disabled}
className={getInputClass('postalCode')}
/>
{renderError('postalCode')}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
{renderLabel('Colonia')}
<input
type="text"
value={value.neighborhood || ''}
onChange={(e) => handleFieldChange('neighborhood', e.target.value)}
placeholder="Colonia o fraccionamiento"
disabled={disabled}
className={getInputClass('neighborhood')}
/>
{renderError('neighborhood')}
</div>
<div>
{renderLabel('Ciudad', required)}
<input
type="text"
value={value.city || ''}
onChange={(e) => handleFieldChange('city', e.target.value)}
placeholder="Ciudad o municipio"
disabled={disabled}
className={getInputClass('city')}
/>
{renderError('city')}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<CountrySelect
value={value.countryId}
onChange={handleCountryChange}
disabled={disabled}
required={required}
error={errors.countryId}
label={showLabels ? 'País' : undefined}
/>
<StateSelect
value={value.stateId}
onChange={handleStateChange}
countryId={selectedCountry?.id || value.countryId}
disabled={disabled}
error={errors.stateId}
label={showLabels ? 'Estado / Provincia' : undefined}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,68 @@
import { useCfdiUses, useCfdiUsesForRegime } from '../hooks/useFiscalCatalogs';
import type { PersonType } from '../types';
interface CfdiUseSelectProps {
value: string;
onChange: (value: string) => void;
personType?: PersonType;
regimeCode?: string;
activeOnly?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
error?: string;
required?: boolean;
label?: string;
}
export function CfdiUseSelect({
value,
onChange,
personType,
regimeCode,
activeOnly = true,
placeholder = 'Seleccionar uso de CFDI',
disabled = false,
className = '',
error,
required = false,
label,
}: CfdiUseSelectProps) {
// Use regime specific hook if regimeCode is provided, otherwise use general hook
const allUses = useCfdiUses({ appliesTo: personType, active: activeOnly });
const regimeUses = useCfdiUsesForRegime(regimeCode);
const { uses, loading, error: loadError } = regimeCode ? regimeUses : allUses;
return (
<div className={`flex flex-col gap-1 ${className}`}>
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
error || loadError ? 'border-red-500' : 'border-gray-300'
}`}
required={required}
>
<option value="">{loading ? 'Cargando...' : placeholder}</option>
{uses.map((use) => (
<option key={use.id} value={use.id}>
{use.code} - {use.name}
</option>
))}
</select>
{(error || loadError) && (
<span className="text-sm text-red-500">{error || loadError}</span>
)}
</div>
);
}
export default CfdiUseSelect;

View File

@ -0,0 +1,144 @@
import React from 'react';
import { useConversionTable } from '../hooks';
interface ConversionTableDisplayProps {
categoryId: string | null;
className?: string;
compact?: boolean;
}
export const ConversionTableDisplay: React.FC<ConversionTableDisplayProps> = ({
categoryId,
className = '',
compact = false,
}) => {
const { table, isLoading, error } = useConversionTable(categoryId);
if (!categoryId) {
return (
<div className={`rounded-lg bg-gray-50 p-4 text-center ${className}`}>
<p className="text-sm text-gray-500">Seleccione una categoría para ver las conversiones</p>
</div>
);
}
if (isLoading) {
return (
<div className={`rounded-lg bg-gray-50 p-4 ${className}`}>
<div className="flex items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-sm text-gray-500">Cargando conversiones...</span>
</div>
</div>
);
}
if (error) {
return (
<div className={`rounded-lg bg-red-50 p-4 ${className}`}>
<p className="text-sm text-red-600">{error.message}</p>
</div>
);
}
if (!table || table.units.length === 0) {
return (
<div className={`rounded-lg bg-gray-50 p-4 text-center ${className}`}>
<p className="text-sm text-gray-500">No hay unidades en esta categoría</p>
</div>
);
}
const { categoryName, referenceUnit, units, conversions } = table;
if (compact) {
return (
<div className={`rounded-lg border border-gray-200 p-3 ${className}`}>
<p className="mb-2 text-xs font-medium text-gray-600">{categoryName}</p>
<div className="space-y-1">
{conversions.slice(0, 5).map((conv, idx) => (
<div key={idx} className="flex justify-between text-xs">
<span className="text-gray-600">
1 {conv.fromCode}
</span>
<span className="font-medium text-gray-900">
{conv.factor.toFixed(4)} {conv.toCode}
</span>
</div>
))}
{conversions.length > 5 && (
<p className="text-xs text-gray-400">
+{conversions.length - 5} más...
</p>
)}
</div>
</div>
);
}
return (
<div className={`rounded-lg border border-gray-200 ${className}`}>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-medium text-gray-900">{categoryName}</h3>
<p className="mt-1 text-xs text-gray-500">
Unidad de referencia: <span className="font-medium">{referenceUnit}</span>
</p>
</div>
<div className="p-4">
<div className="mb-4 flex flex-wrap gap-2">
{units.map((unit) => (
<span
key={unit.id}
className={`
inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium
${unit.isReference
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}
`}
>
{unit.code}
{unit.isReference && (
<span className="ml-1 text-blue-600">*</span>
)}
</span>
))}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
De
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
A
</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-gray-500">
Factor
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{conversions.map((conv, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-3 py-2 font-medium text-gray-900">
1 {conv.fromCode}
</td>
<td className="whitespace-nowrap px-3 py-2 text-gray-600">
{conv.toCode}
</td>
<td className="whitespace-nowrap px-3 py-2 text-right font-mono text-gray-900">
{conv.factor.toFixed(6)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,79 @@
import React from 'react';
import { useCountries } from '../hooks';
import type { Country } from '../types';
interface CountrySelectProps {
value?: string;
onChange: (country: Country | null) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
}
export const CountrySelect: React.FC<CountrySelectProps> = ({
value,
onChange,
placeholder = 'Seleccionar país',
disabled = false,
required = false,
className = '',
error,
label,
}) => {
const { countries, isLoading, error: loadError } = useCountries();
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedId = e.target.value;
if (!selectedId) {
onChange(null);
return;
}
const country = countries.find((c) => c.id === selectedId);
onChange(country || null);
};
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error || loadError
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<select
value={value || ''}
onChange={handleChange}
disabled={disabled || isLoading}
required={required}
className={`${baseClasses} ${errorClasses}`}
>
<option value="">
{isLoading ? 'Cargando...' : placeholder}
</option>
{countries.map((country) => (
<option key={country.id} value={country.id}>
{country.name} ({country.code})
</option>
))}
</select>
{(error || loadError) && (
<p className="mt-1 text-sm text-red-600">
{error || loadError?.message}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,139 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrencies, useCurrencyConversion } from '../hooks';
import type { Currency, CurrencyConversion } from '../types';
interface CurrencyInputProps {
value?: number;
onChange: (amount: number) => void;
currencyId?: string;
baseCurrencyId?: string;
showConversion?: boolean;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
min?: number;
max?: number;
step?: number;
}
export const CurrencyInput: React.FC<CurrencyInputProps> = ({
value,
onChange,
currencyId,
baseCurrencyId,
showConversion = false,
placeholder = '0.00',
disabled = false,
required = false,
className = '',
error,
label,
min = 0,
max,
step = 0.01,
}) => {
const { currencies } = useCurrencies();
const { convert, isLoading: isConverting } = useCurrencyConversion();
const [conversion, setConversion] = useState<CurrencyConversion | null>(null);
const selectedCurrency = currencies.find((c) => c.id === currencyId);
const baseCurrency = currencies.find((c) => c.id === baseCurrencyId);
const fetchConversion = useCallback(async () => {
if (!showConversion || !value || !currencyId || !baseCurrencyId || currencyId === baseCurrencyId) {
setConversion(null);
return;
}
const fromCurrency = currencies.find((c) => c.id === currencyId);
const toCurrency = currencies.find((c) => c.id === baseCurrencyId);
if (!fromCurrency || !toCurrency) return;
const result = await convert(value, fromCurrency.code, toCurrency.code);
setConversion(result);
}, [showConversion, value, currencyId, baseCurrencyId, currencies, convert]);
useEffect(() => {
const timeoutId = setTimeout(fetchConversion, 500);
return () => clearTimeout(timeoutId);
}, [fetchConversion]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.target.value) || 0;
onChange(newValue);
};
const formatAmount = (amount: number, currency?: Currency) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: currency?.code || 'MXN',
minimumFractionDigits: currency?.decimals ?? 2,
maximumFractionDigits: currency?.decimals ?? 2,
}).format(amount);
};
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm text-right
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative">
{selectedCurrency?.symbol && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">
{selectedCurrency.symbol}
</span>
)}
<input
type="number"
value={value || ''}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
required={required}
min={min}
max={max}
step={step}
className={`${baseClasses} ${errorClasses} ${selectedCurrency?.symbol ? 'pl-8' : ''}`}
/>
{selectedCurrency && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">
{selectedCurrency.code}
</span>
)}
</div>
{showConversion && conversion && (
<p className="mt-1 text-xs text-gray-500">
{isConverting ? (
'Calculando...'
) : (
<>
{formatAmount(conversion.convertedAmount, baseCurrency)}
<span className="ml-1 text-gray-400">
(TC: {conversion.rate.toFixed(4)})
</span>
</>
)}
</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,127 @@
import React from 'react';
import { useLatestRates } from '../hooks';
interface CurrencyRatesDisplayProps {
baseCurrency?: string;
className?: string;
compact?: boolean;
}
export const CurrencyRatesDisplay: React.FC<CurrencyRatesDisplayProps> = ({
baseCurrency = 'MXN',
className = '',
compact = false,
}) => {
const { data, isLoading, error, refresh } = useLatestRates(baseCurrency);
if (isLoading) {
return (
<div className={`rounded-lg bg-gray-50 p-4 ${className}`}>
<div className="flex items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-sm text-gray-500">Cargando tipos de cambio...</span>
</div>
</div>
);
}
if (error) {
return (
<div className={`rounded-lg bg-red-50 p-4 ${className}`}>
<p className="text-sm text-red-600">{error.message}</p>
<button
onClick={refresh}
className="mt-2 text-sm text-red-700 underline hover:no-underline"
>
Reintentar
</button>
</div>
);
}
if (!data || Object.keys(data.rates).length === 0) {
return (
<div className={`rounded-lg bg-gray-50 p-4 text-center ${className}`}>
<p className="text-sm text-gray-500">No hay tipos de cambio disponibles</p>
</div>
);
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
const rates = Object.entries(data.rates);
if (compact) {
return (
<div className={`rounded-lg border border-gray-200 p-3 ${className}`}>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium text-gray-600">
Tipos de Cambio ({baseCurrency})
</p>
<p className="text-xs text-gray-400">{formatDate(data.date)}</p>
</div>
<div className="grid grid-cols-2 gap-2">
{rates.slice(0, 4).map(([currency, rate]) => (
<div key={currency} className="flex justify-between text-xs">
<span className="text-gray-600">{currency}</span>
<span className="font-medium text-gray-900">{rate.toFixed(4)}</span>
</div>
))}
</div>
{rates.length > 4 && (
<p className="mt-1 text-xs text-gray-400">+{rates.length - 4} más</p>
)}
</div>
);
}
return (
<div className={`rounded-lg border border-gray-200 ${className}`}>
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<div>
<h3 className="text-sm font-medium text-gray-900">Tipos de Cambio</h3>
<p className="mt-1 text-xs text-gray-500">
Base: <span className="font-medium">{baseCurrency}</span> |
Fecha: <span className="font-medium">{formatDate(data.date)}</span>
</p>
</div>
<button
onClick={refresh}
className="rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Actualizar
</button>
</div>
<div className="p-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rates.map(([currency, rate]) => (
<div
key={currency}
className="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2"
>
<div>
<span className="text-sm font-medium text-gray-900">{currency}</span>
</div>
<div className="text-right">
<span className="font-mono text-sm font-semibold text-gray-900">
{rate.toFixed(4)}
</span>
<p className="text-xs text-gray-500">
1 {currency} = {(1 / rate).toFixed(4)} {baseCurrency}
</p>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,84 @@
import React from 'react';
import { useCurrencies } from '../hooks';
import type { Currency } from '../types';
interface CurrencySelectProps {
value?: string;
onChange: (currency: Currency | null) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
showSymbol?: boolean;
activeOnly?: boolean;
}
export const CurrencySelect: React.FC<CurrencySelectProps> = ({
value,
onChange,
placeholder = 'Seleccionar moneda',
disabled = false,
required = false,
className = '',
error,
label,
showSymbol = true,
activeOnly = true,
}) => {
const { currencies, isLoading, error: loadError } = useCurrencies(activeOnly);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedId = e.target.value;
if (!selectedId) {
onChange(null);
return;
}
const currency = currencies.find((c) => c.id === selectedId);
onChange(currency || null);
};
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error || loadError
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<select
value={value || ''}
onChange={handleChange}
disabled={disabled || isLoading}
required={required}
className={`${baseClasses} ${errorClasses}`}
>
<option value="">
{isLoading ? 'Cargando...' : placeholder}
</option>
{currencies.map((currency) => (
<option key={currency.id} value={currency.id}>
{currency.code} - {currency.name}
{showSymbol && currency.symbol && ` (${currency.symbol})`}
</option>
))}
</select>
{(error || loadError) && (
<p className="mt-1 text-sm text-red-600">
{error || loadError?.message}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,130 @@
import { FiscalRegimeSelect } from './FiscalRegimeSelect';
import { CfdiUseSelect } from './CfdiUseSelect';
import { FiscalPaymentMethodSelect } from './FiscalPaymentMethodSelect';
import { FiscalPaymentTypeSelect } from './FiscalPaymentTypeSelect';
import type { PersonType } from '../types';
export interface FiscalData {
taxId?: string;
fiscalRegimeId?: string;
cfdiUseId?: string;
paymentMethodId?: string;
paymentTypeId?: string;
}
interface FiscalDataInputProps {
value: FiscalData;
onChange: (value: FiscalData) => void;
personType?: PersonType;
showTaxId?: boolean;
showPaymentFields?: boolean;
disabled?: boolean;
className?: string;
errors?: {
taxId?: string;
fiscalRegimeId?: string;
cfdiUseId?: string;
paymentMethodId?: string;
paymentTypeId?: string;
};
}
export function FiscalDataInput({
value,
onChange,
personType = 'both',
showTaxId = true,
showPaymentFields = false,
disabled = false,
className = '',
errors = {},
}: FiscalDataInputProps) {
const handleChange = (field: keyof FiscalData, fieldValue: string) => {
onChange({ ...value, [field]: fieldValue });
};
const handleRegimeChange = (regimeId: string) => {
handleChange('fiscalRegimeId', regimeId);
// Reset CFDI use when regime changes
handleChange('cfdiUseId', '');
};
return (
<div className={`space-y-4 ${className}`}>
<h3 className="text-lg font-medium text-gray-900">Datos Fiscales</h3>
{showTaxId && (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">
RFC <span className="text-red-500">*</span>
</label>
<input
type="text"
value={value.taxId || ''}
onChange={(e) => handleChange('taxId', e.target.value.toUpperCase())}
placeholder="XAXX010101000"
disabled={disabled}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed uppercase ${
errors.taxId ? 'border-red-500' : 'border-gray-300'
}`}
maxLength={13}
/>
{errors.taxId && (
<span className="text-sm text-red-500">{errors.taxId}</span>
)}
<p className="text-xs text-gray-500">
{personType === 'natural'
? '13 caracteres para persona física'
: personType === 'legal'
? '12 caracteres para persona moral'
: '12-13 caracteres'}
</p>
</div>
)}
<FiscalRegimeSelect
value={value.fiscalRegimeId || ''}
onChange={handleRegimeChange}
personType={personType}
disabled={disabled}
label="Régimen Fiscal"
required
error={errors.fiscalRegimeId}
/>
<CfdiUseSelect
value={value.cfdiUseId || ''}
onChange={(v) => handleChange('cfdiUseId', v)}
personType={personType}
disabled={disabled}
label="Uso del CFDI"
required
error={errors.cfdiUseId}
/>
{showPaymentFields && (
<>
<FiscalPaymentMethodSelect
value={value.paymentMethodId || ''}
onChange={(v) => handleChange('paymentMethodId', v)}
disabled={disabled}
label="Forma de Pago (SAT)"
required
error={errors.paymentMethodId}
/>
<FiscalPaymentTypeSelect
value={value.paymentTypeId || ''}
onChange={(v) => handleChange('paymentTypeId', v)}
disabled={disabled}
label="Método de Pago (SAT)"
required
error={errors.paymentTypeId}
/>
</>
)}
</div>
);
}
export default FiscalDataInput;

View File

@ -0,0 +1,59 @@
import { useFiscalPaymentMethods } from '../hooks/useFiscalCatalogs';
interface FiscalPaymentMethodSelectProps {
value: string;
onChange: (value: string) => void;
activeOnly?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
error?: string;
required?: boolean;
label?: string;
}
export function FiscalPaymentMethodSelect({
value,
onChange,
activeOnly = true,
placeholder = 'Seleccionar forma de pago',
disabled = false,
className = '',
error,
required = false,
label,
}: FiscalPaymentMethodSelectProps) {
const { methods, loading, error: loadError } = useFiscalPaymentMethods({ active: activeOnly });
return (
<div className={`flex flex-col gap-1 ${className}`}>
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
error || loadError ? 'border-red-500' : 'border-gray-300'
}`}
required={required}
>
<option value="">{loading ? 'Cargando...' : placeholder}</option>
{methods.map((method) => (
<option key={method.id} value={method.id}>
{method.code} - {method.name}
</option>
))}
</select>
{(error || loadError) && (
<span className="text-sm text-red-500">{error || loadError}</span>
)}
</div>
);
}
export default FiscalPaymentMethodSelect;

View File

@ -0,0 +1,62 @@
import { useFiscalPaymentTypes } from '../hooks/useFiscalCatalogs';
interface FiscalPaymentTypeSelectProps {
value: string;
onChange: (value: string) => void;
activeOnly?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
error?: string;
required?: boolean;
label?: string;
}
export function FiscalPaymentTypeSelect({
value,
onChange,
activeOnly = true,
placeholder = 'Seleccionar método de pago',
disabled = false,
className = '',
error,
required = false,
label,
}: FiscalPaymentTypeSelectProps) {
const { types, loading, error: loadError } = useFiscalPaymentTypes({ active: activeOnly });
return (
<div className={`flex flex-col gap-1 ${className}`}>
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
error || loadError ? 'border-red-500' : 'border-gray-300'
}`}
required={required}
>
<option value="">{loading ? 'Cargando...' : placeholder}</option>
{types.map((type) => (
<option key={type.id} value={type.id}>
{type.code} - {type.name}
</option>
))}
</select>
{(error || loadError) && (
<span className="text-sm text-red-500">{error || loadError}</span>
)}
<p className="text-xs text-gray-500 mt-1">
PUE: Pago en una exhibición | PPD: Pago en parcialidades o diferido
</p>
</div>
);
}
export default FiscalPaymentTypeSelect;

View File

@ -0,0 +1,66 @@
import { useFiscalRegimes, useFiscalRegimesByPersonType } from '../hooks/useFiscalCatalogs';
import type { PersonType } from '../types';
interface FiscalRegimeSelectProps {
value: string;
onChange: (value: string) => void;
personType?: PersonType;
activeOnly?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
error?: string;
required?: boolean;
label?: string;
}
export function FiscalRegimeSelect({
value,
onChange,
personType,
activeOnly = true,
placeholder = 'Seleccionar régimen fiscal',
disabled = false,
className = '',
error,
required = false,
label,
}: FiscalRegimeSelectProps) {
// Use person type specific hook if personType is provided
const allRegimes = useFiscalRegimes({ active: activeOnly });
const filteredRegimes = useFiscalRegimesByPersonType(personType);
const { regimes, loading, error: loadError } = personType ? filteredRegimes : allRegimes;
return (
<div className={`flex flex-col gap-1 ${className}`}>
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
error || loadError ? 'border-red-500' : 'border-gray-300'
}`}
required={required}
>
<option value="">{loading ? 'Cargando...' : placeholder}</option>
{regimes.map((regime) => (
<option key={regime.id} value={regime.id}>
{regime.code} - {regime.name}
</option>
))}
</select>
{(error || loadError) && (
<span className="text-sm text-red-500">{error || loadError}</span>
)}
</div>
);
}
export default FiscalRegimeSelect;

View File

@ -0,0 +1,89 @@
import React from 'react';
import { useStates } from '../hooks';
import type { State } from '../types';
interface StateSelectProps {
value?: string;
onChange: (state: State | null) => void;
countryId?: string;
countryCode?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
}
export const StateSelect: React.FC<StateSelectProps> = ({
value,
onChange,
countryId,
countryCode,
placeholder = 'Seleccionar estado',
disabled = false,
required = false,
className = '',
error,
label,
}) => {
const { states, isLoading, error: loadError } = useStates(countryId, countryCode);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedId = e.target.value;
if (!selectedId) {
onChange(null);
return;
}
const state = states.find((s) => s.id === selectedId);
onChange(state || null);
};
const hasCountryFilter = countryId || countryCode;
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error || loadError
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<select
value={value || ''}
onChange={handleChange}
disabled={disabled || isLoading || !hasCountryFilter}
required={required}
className={`${baseClasses} ${errorClasses}`}
>
<option value="">
{!hasCountryFilter
? 'Seleccione un país primero'
: isLoading
? 'Cargando...'
: placeholder}
</option>
{states.map((state) => (
<option key={state.id} value={state.id}>
{state.name} ({state.code})
</option>
))}
</select>
{(error || loadError) && (
<p className="mt-1 text-sm text-red-600">
{error || loadError?.message}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,65 @@
import { useTaxCategories } from '../hooks/useFiscalCatalogs';
import type { TaxNature } from '../types';
interface TaxCategorySelectProps {
value: string;
onChange: (value: string) => void;
taxNature?: TaxNature;
activeOnly?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
error?: string;
required?: boolean;
label?: string;
}
export function TaxCategorySelect({
value,
onChange,
taxNature,
activeOnly = true,
placeholder = 'Seleccionar categoría de impuesto',
disabled = false,
className = '',
error,
required = false,
label,
}: TaxCategorySelectProps) {
const { categories, loading, error: loadError } = useTaxCategories({
taxNature,
active: activeOnly,
});
return (
<div className={`flex flex-col gap-1 ${className}`}>
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
error || loadError ? 'border-red-500' : 'border-gray-300'
}`}
required={required}
>
<option value="">{loading ? 'Cargando...' : placeholder}</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.code} - {cat.name}
</option>
))}
</select>
{(error || loadError) && (
<span className="text-sm text-red-500">{error || loadError}</span>
)}
</div>
);
}
export default TaxCategorySelect;

View File

@ -0,0 +1,79 @@
import React from 'react';
import { useUomCategories } from '../hooks';
import type { UomCategory } from '../types';
interface UomCategorySelectProps {
value?: string;
onChange: (category: UomCategory | null) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
}
export const UomCategorySelect: React.FC<UomCategorySelectProps> = ({
value,
onChange,
placeholder = 'Seleccionar categoría',
disabled = false,
required = false,
className = '',
error,
label,
}) => {
const { categories, isLoading, error: loadError } = useUomCategories();
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedId = e.target.value;
if (!selectedId) {
onChange(null);
return;
}
const category = categories.find((c) => c.id === selectedId);
onChange(category || null);
};
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error || loadError
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<select
value={value || ''}
onChange={handleChange}
disabled={disabled || isLoading}
required={required}
className={`${baseClasses} ${errorClasses}`}
>
<option value="">
{isLoading ? 'Cargando...' : placeholder}
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{(error || loadError) && (
<p className="mt-1 text-sm text-red-600">
{error || loadError?.message}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,128 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useUom, useUomConversion } from '../hooks';
import type { Uom, UomConversion } from '../types';
interface UomQuantityInputProps {
value?: number;
onChange: (quantity: number) => void;
uomId?: string;
targetUomId?: string;
categoryId?: string;
showConversion?: boolean;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
min?: number;
max?: number;
step?: number;
}
export const UomQuantityInput: React.FC<UomQuantityInputProps> = ({
value,
onChange,
uomId,
targetUomId,
categoryId,
showConversion = false,
placeholder = '0',
disabled = false,
required = false,
className = '',
error,
label,
min = 0,
max,
step = 1,
}) => {
const { uoms } = useUom(categoryId);
const { convert, isLoading: isConverting } = useUomConversion();
const [conversion, setConversion] = useState<UomConversion | null>(null);
const selectedUom = uoms.find((u) => u.id === uomId);
const targetUom = uoms.find((u) => u.id === targetUomId);
const fetchConversion = useCallback(async () => {
if (!showConversion || !value || !uomId || !targetUomId || uomId === targetUomId) {
setConversion(null);
return;
}
const result = await convert(value, uomId, targetUomId);
setConversion(result);
}, [showConversion, value, uomId, targetUomId, convert]);
useEffect(() => {
const timeoutId = setTimeout(fetchConversion, 300);
return () => clearTimeout(timeoutId);
}, [fetchConversion]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.target.value) || 0;
onChange(newValue);
};
const formatQuantity = (qty: number, uom?: Uom) => {
const decimals = uom?.code?.includes('kg') || uom?.code?.includes('l') ? 3 : 2;
return `${qty.toFixed(decimals)} ${uom?.code || ''}`;
};
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm text-right
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative">
<input
type="number"
value={value || ''}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
required={required}
min={min}
max={max}
step={step}
className={`${baseClasses} ${errorClasses} pr-16`}
/>
{selectedUom && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">
{selectedUom.code}
</span>
)}
</div>
{showConversion && conversion && (
<p className="mt-1 text-xs text-gray-500">
{isConverting ? (
'Calculando...'
) : (
<>
= {formatQuantity(conversion.convertedQuantity, targetUom)}
<span className="ml-1 text-gray-400">
(factor: {conversion.factor.toFixed(6)})
</span>
</>
)}
</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
};

View File

@ -0,0 +1,83 @@
import React from 'react';
import { useUom } from '../hooks';
import type { Uom } from '../types';
interface UomSelectProps {
value?: string;
onChange: (uom: Uom | null) => void;
categoryId?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
error?: string;
label?: string;
activeOnly?: boolean;
}
export const UomSelect: React.FC<UomSelectProps> = ({
value,
onChange,
categoryId,
placeholder = 'Seleccionar unidad',
disabled = false,
required = false,
className = '',
error,
label,
activeOnly = true,
}) => {
const { uoms, isLoading, error: loadError } = useUom(categoryId, activeOnly);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedId = e.target.value;
if (!selectedId) {
onChange(null);
return;
}
const uom = uoms.find((u) => u.id === selectedId);
onChange(uom || null);
};
const baseClasses = `
w-full rounded-md border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
`;
const errorClasses = error || loadError
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300';
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<select
value={value || ''}
onChange={handleChange}
disabled={disabled || isLoading}
required={required}
className={`${baseClasses} ${errorClasses}`}
>
<option value="">
{isLoading ? 'Cargando...' : placeholder}
</option>
{uoms.map((uom) => (
<option key={uom.id} value={uom.id}>
{uom.name} ({uom.code})
</option>
))}
</select>
{(error || loadError) && (
<p className="mt-1 text-sm text-red-600">
{error || loadError?.message}
</p>
)}
</div>
);
};

View File

@ -0,0 +1,67 @@
import { useWithholdingTypes, useWithholdingTypesByCategory } from '../hooks/useFiscalCatalogs';
interface WithholdingTypeSelectProps {
value: string;
onChange: (value: string) => void;
taxCategoryId?: string;
activeOnly?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
error?: string;
required?: boolean;
label?: string;
showRate?: boolean;
}
export function WithholdingTypeSelect({
value,
onChange,
taxCategoryId,
activeOnly = true,
placeholder = 'Seleccionar tipo de retención',
disabled = false,
className = '',
error,
required = false,
label,
showRate = true,
}: WithholdingTypeSelectProps) {
// Use category specific hook if taxCategoryId is provided
const allTypes = useWithholdingTypes({ active: activeOnly });
const categoryTypes = useWithholdingTypesByCategory(taxCategoryId);
const { types, loading, error: loadError } = taxCategoryId ? categoryTypes : allTypes;
return (
<div className={`flex flex-col gap-1 ${className}`}>
{label && (
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
error || loadError ? 'border-red-500' : 'border-gray-300'
}`}
required={required}
>
<option value="">{loading ? 'Cargando...' : placeholder}</option>
{types.map((type) => (
<option key={type.id} value={type.id}>
{type.code} - {type.name} {showRate && `(${type.defaultRate}%)`}
</option>
))}
</select>
{(error || loadError) && (
<span className="text-sm text-red-500">{error || loadError}</span>
)}
</div>
);
}
export default WithholdingTypeSelect;

View File

@ -0,0 +1,26 @@
// Select components
export { CountrySelect } from './CountrySelect';
export { StateSelect } from './StateSelect';
export { CurrencySelect } from './CurrencySelect';
export { UomCategorySelect } from './UomCategorySelect';
export { UomSelect } from './UomSelect';
// Input components
export { CurrencyInput } from './CurrencyInput';
export { UomQuantityInput } from './UomQuantityInput';
export { AddressInput } from './AddressInput';
export type { AddressData } from './AddressInput';
// Display components
export { ConversionTableDisplay } from './ConversionTableDisplay';
export { CurrencyRatesDisplay } from './CurrencyRatesDisplay';
// Fiscal components
export { FiscalRegimeSelect } from './FiscalRegimeSelect';
export { CfdiUseSelect } from './CfdiUseSelect';
export { FiscalPaymentMethodSelect } from './FiscalPaymentMethodSelect';
export { FiscalPaymentTypeSelect } from './FiscalPaymentTypeSelect';
export { TaxCategorySelect } from './TaxCategorySelect';
export { WithholdingTypeSelect } from './WithholdingTypeSelect';
export { FiscalDataInput } from './FiscalDataInput';
export type { FiscalData } from './FiscalDataInput';

View File

@ -0,0 +1,14 @@
export { useCountries, useCountry, useStates } from './useCountries';
export { useCurrencies, useCurrencyRates, useLatestRates, useCurrencyConversion } from './useCurrencies';
export { useUomCategories, useUom, useUomConversion, useConversionTable } from './useUom';
export {
useTaxCategories,
useFiscalRegimes,
useFiscalRegimesByPersonType,
useCfdiUses,
useCfdiUsesForRegime,
useFiscalPaymentMethods,
useFiscalPaymentTypes,
useWithholdingTypes,
useWithholdingTypesByCategory,
} from './useFiscalCatalogs';

View File

@ -0,0 +1,120 @@
import { useState, useEffect, useCallback } from 'react';
import { countriesApi } from '../api';
import type { Country, State, CreateStateDto, UpdateStateDto } from '../types';
export function useCountries() {
const [countries, setCountries] = useState<Country[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchCountries = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await countriesApi.getAll();
setCountries(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar países'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCountries();
}, [fetchCountries]);
return {
countries,
isLoading,
error,
refresh: fetchCountries,
};
}
export function useCountry(id: string | null) {
const [country, setCountry] = useState<Country | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!id) {
setCountry(null);
return;
}
const fetch = async () => {
setIsLoading(true);
setError(null);
try {
const data = await countriesApi.getById(id);
setCountry(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar país'));
} finally {
setIsLoading(false);
}
};
fetch();
}, [id]);
return { country, isLoading, error };
}
export function useStates(countryId?: string, countryCode?: string) {
const [states, setStates] = useState<State[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchStates = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
let data: State[];
if (countryId) {
data = await countriesApi.getStatesByCountry(countryId);
} else if (countryCode) {
data = await countriesApi.getStatesByCountryCode(countryCode);
} else {
data = await countriesApi.getStates({ active: true });
}
setStates(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar estados'));
} finally {
setIsLoading(false);
}
}, [countryId, countryCode]);
useEffect(() => {
fetchStates();
}, [fetchStates]);
const createState = useCallback(async (data: CreateStateDto): Promise<State> => {
const newState = await countriesApi.createState(data);
setStates(prev => [...prev, newState]);
return newState;
}, []);
const updateState = useCallback(async (id: string, data: UpdateStateDto): Promise<State> => {
const updated = await countriesApi.updateState(id, data);
setStates(prev => prev.map(s => s.id === id ? updated : s));
return updated;
}, []);
const deleteState = useCallback(async (id: string): Promise<void> => {
await countriesApi.deleteState(id);
setStates(prev => prev.filter(s => s.id !== id));
}, []);
return {
states,
isLoading,
error,
refresh: fetchStates,
createState,
updateState,
deleteState,
};
}

View File

@ -0,0 +1,158 @@
import { useState, useEffect, useCallback } from 'react';
import { currenciesApi } from '../api';
import type {
Currency,
CreateCurrencyDto,
UpdateCurrencyDto,
CurrencyRate,
CreateCurrencyRateDto,
CurrencyConversion,
LatestRates,
} from '../types';
export function useCurrencies(activeOnly = true) {
const [currencies, setCurrencies] = useState<Currency[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchCurrencies = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await currenciesApi.getAll(activeOnly);
setCurrencies(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar monedas'));
} finally {
setIsLoading(false);
}
}, [activeOnly]);
useEffect(() => {
fetchCurrencies();
}, [fetchCurrencies]);
const createCurrency = useCallback(async (data: CreateCurrencyDto): Promise<Currency> => {
const newCurrency = await currenciesApi.create(data);
setCurrencies(prev => [...prev, newCurrency]);
return newCurrency;
}, []);
const updateCurrency = useCallback(async (id: string, data: UpdateCurrencyDto): Promise<Currency> => {
const updated = await currenciesApi.update(id, data);
setCurrencies(prev => prev.map(c => c.id === id ? updated : c));
return updated;
}, []);
return {
currencies,
isLoading,
error,
refresh: fetchCurrencies,
createCurrency,
updateCurrency,
};
}
export function useCurrencyRates(from?: string, to?: string) {
const [rates, setRates] = useState<CurrencyRate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchRates = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await currenciesApi.getRates({ from, to });
setRates(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar tipos de cambio'));
} finally {
setIsLoading(false);
}
}, [from, to]);
useEffect(() => {
fetchRates();
}, [fetchRates]);
const createRate = useCallback(async (data: CreateCurrencyRateDto): Promise<CurrencyRate> => {
const newRate = await currenciesApi.createRate(data);
setRates(prev => [newRate, ...prev]);
return newRate;
}, []);
const deleteRate = useCallback(async (id: string): Promise<void> => {
await currenciesApi.deleteRate(id);
setRates(prev => prev.filter(r => r.id !== id));
}, []);
return {
rates,
isLoading,
error,
refresh: fetchRates,
createRate,
deleteRate,
};
}
export function useLatestRates(baseCurrency = 'MXN') {
const [data, setData] = useState<LatestRates | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchRates = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await currenciesApi.getLatestRates(baseCurrency);
setData(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar tipos de cambio'));
} finally {
setIsLoading(false);
}
}, [baseCurrency]);
useEffect(() => {
fetchRates();
}, [fetchRates]);
return {
data,
isLoading,
error,
refresh: fetchRates,
};
}
export function useCurrencyConversion() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const convert = useCallback(async (
amount: number,
from: string,
to: string,
date?: string
): Promise<CurrencyConversion | null> => {
setIsLoading(true);
setError(null);
try {
const result = await currenciesApi.convert(amount, from, to, date);
return result;
} catch (err) {
setError(err instanceof Error ? err : new Error('Error en conversión'));
return null;
} finally {
setIsLoading(false);
}
}, []);
return {
convert,
isLoading,
error,
};
}

View File

@ -0,0 +1,276 @@
import { useState, useEffect, useCallback } from 'react';
import {
taxCategoriesApi,
fiscalRegimesApi,
cfdiUsesApi,
fiscalPaymentMethodsApi,
fiscalPaymentTypesApi,
withholdingTypesApi,
TaxCategoryFilter,
FiscalRegimeFilter,
CfdiUseFilter,
PaymentMethodFilter,
PaymentTypeFilter,
WithholdingTypeFilter,
} from '../api/fiscal.api';
import type {
TaxCategory,
FiscalRegime,
CfdiUse,
FiscalPaymentMethod,
FiscalPaymentType,
WithholdingType,
PersonType,
} from '../types';
// ========== TAX CATEGORIES HOOK ==========
export function useTaxCategories(filter?: TaxCategoryFilter) {
const [categories, setCategories] = useState<TaxCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchCategories = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await taxCategoriesApi.getAll(filter);
setCategories(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar categorías de impuestos');
} finally {
setLoading(false);
}
}, [filter?.taxNature, filter?.active]);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
return { categories, loading, error, refetch: fetchCategories };
}
// ========== FISCAL REGIMES HOOK ==========
export function useFiscalRegimes(filter?: FiscalRegimeFilter) {
const [regimes, setRegimes] = useState<FiscalRegime[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchRegimes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fiscalRegimesApi.getAll(filter);
setRegimes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar regímenes fiscales');
} finally {
setLoading(false);
}
}, [filter?.appliesTo, filter?.active]);
useEffect(() => {
fetchRegimes();
}, [fetchRegimes]);
return { regimes, loading, error, refetch: fetchRegimes };
}
export function useFiscalRegimesByPersonType(personType: PersonType | undefined) {
const [regimes, setRegimes] = useState<FiscalRegime[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchRegimes = useCallback(async () => {
if (!personType) {
setRegimes([]);
return;
}
setLoading(true);
setError(null);
try {
const data = await fiscalRegimesApi.getForPersonType(personType);
setRegimes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar regímenes fiscales');
} finally {
setLoading(false);
}
}, [personType]);
useEffect(() => {
fetchRegimes();
}, [fetchRegimes]);
return { regimes, loading, error, refetch: fetchRegimes };
}
// ========== CFDI USES HOOK ==========
export function useCfdiUses(filter?: CfdiUseFilter) {
const [uses, setUses] = useState<CfdiUse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchUses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await cfdiUsesApi.getAll(filter);
setUses(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar usos de CFDI');
} finally {
setLoading(false);
}
}, [filter?.appliesTo, filter?.active]);
useEffect(() => {
fetchUses();
}, [fetchUses]);
return { uses, loading, error, refetch: fetchUses };
}
export function useCfdiUsesForRegime(regimeCode: string | undefined) {
const [uses, setUses] = useState<CfdiUse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUses = useCallback(async () => {
if (!regimeCode) {
setUses([]);
return;
}
setLoading(true);
setError(null);
try {
const data = await cfdiUsesApi.getForRegime(regimeCode);
setUses(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar usos de CFDI');
} finally {
setLoading(false);
}
}, [regimeCode]);
useEffect(() => {
fetchUses();
}, [fetchUses]);
return { uses, loading, error, refetch: fetchUses };
}
// ========== PAYMENT METHODS HOOK ==========
export function useFiscalPaymentMethods(filter?: PaymentMethodFilter) {
const [methods, setMethods] = useState<FiscalPaymentMethod[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchMethods = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fiscalPaymentMethodsApi.getAll(filter);
setMethods(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar formas de pago');
} finally {
setLoading(false);
}
}, [filter?.requiresBankInfo, filter?.active]);
useEffect(() => {
fetchMethods();
}, [fetchMethods]);
return { methods, loading, error, refetch: fetchMethods };
}
// ========== PAYMENT TYPES HOOK ==========
export function useFiscalPaymentTypes(filter?: PaymentTypeFilter) {
const [types, setTypes] = useState<FiscalPaymentType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchTypes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fiscalPaymentTypesApi.getAll(filter);
setTypes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar métodos de pago');
} finally {
setLoading(false);
}
}, [filter?.active]);
useEffect(() => {
fetchTypes();
}, [fetchTypes]);
return { types, loading, error, refetch: fetchTypes };
}
// ========== WITHHOLDING TYPES HOOK ==========
export function useWithholdingTypes(filter?: WithholdingTypeFilter) {
const [types, setTypes] = useState<WithholdingType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchTypes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await withholdingTypesApi.getAll(filter);
setTypes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar tipos de retención');
} finally {
setLoading(false);
}
}, [filter?.taxCategoryId, filter?.active]);
useEffect(() => {
fetchTypes();
}, [fetchTypes]);
return { types, loading, error, refetch: fetchTypes };
}
export function useWithholdingTypesByCategory(categoryId: string | undefined) {
const [types, setTypes] = useState<WithholdingType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTypes = useCallback(async () => {
if (!categoryId) {
setTypes([]);
return;
}
setLoading(true);
setError(null);
try {
const data = await withholdingTypesApi.getByCategory(categoryId);
setTypes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar tipos de retención');
} finally {
setLoading(false);
}
}, [categoryId]);
useEffect(() => {
fetchTypes();
}, [fetchTypes]);
return { types, loading, error, refetch: fetchTypes };
}

View File

@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from 'react';
import { uomApi } from '../api';
import type {
UomCategory,
Uom,
CreateUomDto,
UpdateUomDto,
UomConversion,
ConversionTable,
} from '../types';
export function useUomCategories() {
const [categories, setCategories] = useState<UomCategory[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchCategories = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await uomApi.getCategories();
setCategories(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar categorías de UdM'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
return {
categories,
isLoading,
error,
refresh: fetchCategories,
};
}
export function useUom(categoryId?: string, activeOnly = true) {
const [uoms, setUoms] = useState<Uom[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchUoms = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await uomApi.getAll({
categoryId,
active: activeOnly,
});
setUoms(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar unidades de medida'));
} finally {
setIsLoading(false);
}
}, [categoryId, activeOnly]);
useEffect(() => {
fetchUoms();
}, [fetchUoms]);
const createUom = useCallback(async (data: CreateUomDto): Promise<Uom> => {
const newUom = await uomApi.create(data);
setUoms(prev => [...prev, newUom]);
return newUom;
}, []);
const updateUom = useCallback(async (id: string, data: UpdateUomDto): Promise<Uom> => {
const updated = await uomApi.update(id, data);
setUoms(prev => prev.map(u => u.id === id ? updated : u));
return updated;
}, []);
return {
uoms,
isLoading,
error,
refresh: fetchUoms,
createUom,
updateUom,
};
}
export function useUomConversion() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const convert = useCallback(async (
quantity: number,
fromUomId: string,
toUomId: string
): Promise<UomConversion | null> => {
setIsLoading(true);
setError(null);
try {
const result = await uomApi.convert(quantity, fromUomId, toUomId);
return result;
} catch (err) {
setError(err instanceof Error ? err : new Error('Error en conversión de UdM'));
return null;
} finally {
setIsLoading(false);
}
}, []);
return {
convert,
isLoading,
error,
};
}
export function useConversionTable(categoryId: string | null) {
const [table, setTable] = useState<ConversionTable | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!categoryId) {
setTable(null);
return;
}
const fetch = async () => {
setIsLoading(true);
setError(null);
try {
const data = await uomApi.getConversionTable(categoryId);
setTable(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error al cargar tabla de conversiones'));
} finally {
setIsLoading(false);
}
};
fetch();
}, [categoryId]);
return {
table,
isLoading,
error,
};
}

View File

@ -0,0 +1,36 @@
// Types
export * from './types';
// API
export { countriesApi, currenciesApi, uomApi } from './api';
// Hooks
export {
useCountries,
useCountry,
useStates,
useCurrencies,
useCurrencyRates,
useLatestRates,
useCurrencyConversion,
useUomCategories,
useUom,
useUomConversion,
useConversionTable,
} from './hooks';
// Components
export {
CountrySelect,
StateSelect,
CurrencySelect,
UomCategorySelect,
UomSelect,
CurrencyInput,
UomQuantityInput,
AddressInput,
ConversionTableDisplay,
CurrencyRatesDisplay,
} from './components';
export type { AddressData } from './components';

View File

@ -0,0 +1,358 @@
// Country types
export interface Country {
id: string;
code: string;
codeAlpha3?: string;
name: string;
phoneCode?: string;
currencyCode?: string;
createdAt: string;
}
// State types
export interface State {
id: string;
countryId: string;
country?: Country;
code: string;
name: string;
timezone?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateStateDto {
countryId: string;
code: string;
name: string;
timezone?: string;
isActive?: boolean;
}
export interface UpdateStateDto {
name?: string;
timezone?: string;
isActive?: boolean;
}
// Currency types
export interface Currency {
id: string;
code: string;
name: string;
symbol: string;
decimals: number;
rounding: number;
active: boolean;
createdAt: string;
}
export interface CreateCurrencyDto {
code: string;
name: string;
symbol: string;
decimals?: number;
}
export interface UpdateCurrencyDto {
name?: string;
symbol?: string;
decimals?: number;
active?: boolean;
}
// Currency Rate types
export interface CurrencyRate {
id: string;
tenantId?: string;
fromCurrencyId: string;
fromCurrency?: Currency;
toCurrencyId: string;
toCurrency?: Currency;
rate: number;
rateDate: string;
source: 'manual' | 'banxico' | 'xe' | 'openexchange';
createdBy?: string;
createdAt: string;
}
export interface CreateCurrencyRateDto {
fromCurrencyCode: string;
toCurrencyCode: string;
rate: number;
rateDate?: string;
source?: 'manual' | 'banxico' | 'xe' | 'openexchange';
}
export interface CurrencyConversion {
originalAmount: number;
convertedAmount: number;
rate: number;
from: string;
to: string;
}
export interface LatestRates {
base: string;
rates: Record<string, number>;
date: string;
}
// UoM Category types
export interface UomCategory {
id: string;
tenantId?: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}
// UoM types
export type UomType = 'reference' | 'bigger' | 'smaller';
export interface Uom {
id: string;
tenantId?: string;
categoryId: string;
category?: UomCategory;
code: string;
name: string;
symbol: string;
uomType: UomType;
factor: number;
rounding: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateUomDto {
categoryId: string;
name: string;
code: string;
uomType?: UomType;
ratio?: number;
}
export interface UpdateUomDto {
name?: string;
ratio?: number;
active?: boolean;
}
export interface UomConversion {
originalQuantity: number;
originalUom: string;
convertedQuantity: number;
targetUom: string;
factor: number;
}
export interface ConversionTableEntry {
uom: Uom;
toReference: number;
fromReference: number;
}
export interface ConversionTableConversion {
fromCode: string;
toCode: string;
factor: number;
}
export interface ConversionTableUnit {
id: string;
code: string;
name: string;
isReference: boolean;
}
export interface ConversionTable {
categoryName: string;
referenceUnit: string;
referenceUom: Uom;
units: ConversionTableUnit[];
conversions: ConversionTableConversion[];
}
// Product Category types
export interface ProductCategory {
id: string;
tenantId: string;
parentId?: string;
parent?: ProductCategory;
code?: string;
name: string;
description?: string;
hierarchyPath?: string;
hierarchyLevel: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateProductCategoryDto {
code?: string;
name: string;
parentId?: string;
}
export interface UpdateProductCategoryDto {
name?: string;
parentId?: string | null;
active?: boolean;
}
// Payment Term types
export interface PaymentTermLine {
id: string;
paymentTermId: string;
sequence: number;
lineType: 'balance' | 'percent' | 'fixed';
valuePercent?: number;
valueAmount?: number;
days: number;
dayOfMonth?: number;
endOfMonth?: boolean;
}
export interface PaymentTerm {
id: string;
tenantId: string;
code: string;
name: string;
description?: string;
dueDays?: number;
discountPercent?: number;
discountDays?: number;
isImmediate: boolean;
isActive: boolean;
lines: PaymentTermLine[];
createdAt: string;
updatedAt: string;
}
export interface CreatePaymentTermDto {
code: string;
name: string;
description?: string;
dueDays?: number;
discountPercent?: number;
discountDays?: number;
isImmediate?: boolean;
lines?: Partial<PaymentTermLine>[];
}
// Discount Rule types
export type DiscountType = 'percentage' | 'fixed' | 'price_override';
export type DiscountAppliesTo = 'all' | 'category' | 'product' | 'customer' | 'customer_group';
export type DiscountCondition = 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
export interface DiscountRule {
id: string;
tenantId: string;
code: string;
name: string;
description?: string;
discountType: DiscountType;
discountValue: number;
maxDiscountAmount?: number;
appliesTo: DiscountAppliesTo;
appliesToId?: string;
conditionType: DiscountCondition;
conditionValue?: number;
startDate?: string;
endDate?: string;
priority: number;
combinable: boolean;
usageLimit?: number;
usageCount: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// ========== FISCAL TYPES ==========
// Tax Nature
export type TaxNature = 'tax' | 'withholding' | 'both';
// Person Type
export type PersonType = 'natural' | 'legal' | 'both';
// Tax Category (IVA, ISR, IEPS, etc.)
export interface TaxCategory {
id: string;
code: string;
name: string;
description?: string;
taxNature: TaxNature;
satCode?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// Fiscal Regime (SAT c_RegimenFiscal)
export interface FiscalRegime {
id: string;
code: string;
name: string;
description?: string;
appliesTo: PersonType;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// CFDI Use (SAT c_UsoCFDI)
export interface CfdiUse {
id: string;
code: string;
name: string;
description?: string;
appliesTo: PersonType;
allowedRegimes?: string[];
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// Payment Method - SAT Forms of Payment (c_FormaPago)
export interface FiscalPaymentMethod {
id: string;
code: string;
name: string;
description?: string;
requiresBankInfo: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// Payment Type - SAT Payment Methods (c_MetodoPago: PUE, PPD)
export interface FiscalPaymentType {
id: string;
code: string;
name: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// Withholding Type
export interface WithholdingType {
id: string;
code: string;
name: string;
description?: string;
defaultRate: number;
taxCategoryId?: string;
taxCategory?: TaxCategory;
isActive: boolean;
createdAt: string;
updatedAt: string;
}

View File

@ -0,0 +1,228 @@
import { api } from '@services/api/axios-instance';
import type {
Lead,
LeadCreateInput,
LeadUpdateInput,
LeadFilters,
LeadConvertInput,
LeadsResponse,
Opportunity,
OpportunityCreateInput,
OpportunityUpdateInput,
OpportunityFilters,
OpportunitiesResponse,
Pipeline,
Stage,
StageCreateInput,
StageUpdateInput,
StageType,
StagesResponse,
Activity,
ActivityCreateInput,
ActivityUpdateInput,
ActivityFilters,
ActivitiesResponse,
} from '../types';
const CRM_BASE = '/api/crm';
// ============================================================================
// Leads API
// ============================================================================
export const leadsApi = {
getAll: async (filters: LeadFilters = {}): Promise<LeadsResponse> => {
const params = new URLSearchParams();
if (filters.companyId) params.append('company_id', filters.companyId);
if (filters.status) params.append('status', filters.status);
if (filters.stageId) params.append('stage_id', filters.stageId);
if (filters.userId) params.append('user_id', filters.userId);
if (filters.source) params.append('source', filters.source);
if (filters.priority !== undefined) params.append('priority', String(filters.priority));
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<LeadsResponse>(`${CRM_BASE}/leads?${params}`);
return response.data;
},
getById: async (id: string): Promise<Lead> => {
const response = await api.get<Lead>(`${CRM_BASE}/leads/${id}`);
return response.data;
},
create: async (data: LeadCreateInput): Promise<Lead> => {
const response = await api.post<Lead>(`${CRM_BASE}/leads`, data);
return response.data;
},
update: async (id: string, data: LeadUpdateInput): Promise<Lead> => {
const response = await api.patch<Lead>(`${CRM_BASE}/leads/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${CRM_BASE}/leads/${id}`);
},
convert: async (id: string, data: LeadConvertInput): Promise<{ opportunityId?: string; partnerId?: string }> => {
const response = await api.post<{ opportunityId?: string; partnerId?: string }>(
`${CRM_BASE}/leads/${id}/convert`,
data
);
return response.data;
},
markLost: async (id: string, reason?: string): Promise<Lead> => {
const response = await api.post<Lead>(`${CRM_BASE}/leads/${id}/lost`, { reason });
return response.data;
},
};
// ============================================================================
// Opportunities API
// ============================================================================
export const opportunitiesApi = {
getAll: async (filters: OpportunityFilters = {}): Promise<OpportunitiesResponse> => {
const params = new URLSearchParams();
if (filters.companyId) params.append('company_id', filters.companyId);
if (filters.status) params.append('status', filters.status);
if (filters.stageId) params.append('stage_id', filters.stageId);
if (filters.userId) params.append('user_id', filters.userId);
if (filters.partnerId) params.append('partner_id', filters.partnerId);
if (filters.priority !== undefined) params.append('priority', String(filters.priority));
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<OpportunitiesResponse>(`${CRM_BASE}/opportunities?${params}`);
return response.data;
},
getById: async (id: string): Promise<Opportunity> => {
const response = await api.get<Opportunity>(`${CRM_BASE}/opportunities/${id}`);
return response.data;
},
create: async (data: OpportunityCreateInput): Promise<Opportunity> => {
const response = await api.post<Opportunity>(`${CRM_BASE}/opportunities`, data);
return response.data;
},
update: async (id: string, data: OpportunityUpdateInput): Promise<Opportunity> => {
const response = await api.patch<Opportunity>(`${CRM_BASE}/opportunities/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${CRM_BASE}/opportunities/${id}`);
},
markWon: async (id: string): Promise<Opportunity> => {
const response = await api.post<Opportunity>(`${CRM_BASE}/opportunities/${id}/won`);
return response.data;
},
markLost: async (id: string, reason?: string): Promise<Opportunity> => {
const response = await api.post<Opportunity>(`${CRM_BASE}/opportunities/${id}/lost`, { reason });
return response.data;
},
getPipeline: async (companyId?: string): Promise<Pipeline> => {
const params = companyId ? `?company_id=${companyId}` : '';
const response = await api.get<Pipeline>(`${CRM_BASE}/opportunities/pipeline${params}`);
return response.data;
},
};
// ============================================================================
// Stages API
// ============================================================================
export const stagesApi = {
getAll: async (type?: StageType, companyId?: string): Promise<StagesResponse> => {
const params = new URLSearchParams();
if (type) params.append('type', type);
if (companyId) params.append('company_id', companyId);
const response = await api.get<StagesResponse>(`${CRM_BASE}/stages?${params}`);
return response.data;
},
getById: async (id: string): Promise<Stage> => {
const response = await api.get<Stage>(`${CRM_BASE}/stages/${id}`);
return response.data;
},
create: async (data: StageCreateInput): Promise<Stage> => {
const response = await api.post<Stage>(`${CRM_BASE}/stages`, data);
return response.data;
},
update: async (id: string, data: StageUpdateInput): Promise<Stage> => {
const response = await api.patch<Stage>(`${CRM_BASE}/stages/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${CRM_BASE}/stages/${id}`);
},
reorder: async (stageIds: string[]): Promise<void> => {
await api.post(`${CRM_BASE}/stages/reorder`, { stageIds });
},
};
// ============================================================================
// Activities API
// ============================================================================
export const activitiesApi = {
getAll: async (filters: ActivityFilters = {}): Promise<ActivitiesResponse> => {
const params = new URLSearchParams();
if (filters.companyId) params.append('company_id', filters.companyId);
if (filters.type) params.append('type', filters.type);
if (filters.state) params.append('state', filters.state);
if (filters.userId) params.append('user_id', filters.userId);
if (filters.leadId) params.append('lead_id', filters.leadId);
if (filters.opportunityId) params.append('opportunity_id', filters.opportunityId);
if (filters.partnerId) params.append('partner_id', filters.partnerId);
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<ActivitiesResponse>(`${CRM_BASE}/activities?${params}`);
return response.data;
},
getById: async (id: string): Promise<Activity> => {
const response = await api.get<Activity>(`${CRM_BASE}/activities/${id}`);
return response.data;
},
create: async (data: ActivityCreateInput): Promise<Activity> => {
const response = await api.post<Activity>(`${CRM_BASE}/activities`, data);
return response.data;
},
update: async (id: string, data: ActivityUpdateInput): Promise<Activity> => {
const response = await api.patch<Activity>(`${CRM_BASE}/activities/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${CRM_BASE}/activities/${id}`);
},
markDone: async (id: string): Promise<Activity> => {
const response = await api.post<Activity>(`${CRM_BASE}/activities/${id}/done`);
return response.data;
},
cancel: async (id: string): Promise<Activity> => {
const response = await api.post<Activity>(`${CRM_BASE}/activities/${id}/cancel`);
return response.data;
},
};

View File

@ -0,0 +1 @@
export * from './crm.api';

View File

@ -0,0 +1 @@
export * from './useCrm';

View File

@ -0,0 +1,428 @@
import { useState, useEffect, useCallback } from 'react';
import { leadsApi, opportunitiesApi, stagesApi, activitiesApi } from '../api';
import type {
Lead,
LeadCreateInput,
LeadUpdateInput,
LeadFilters,
LeadStatus,
LeadSource,
LeadConvertInput,
Opportunity,
OpportunityCreateInput,
OpportunityUpdateInput,
OpportunityFilters,
OpportunityStatus,
Pipeline,
Stage,
StageCreateInput,
StageUpdateInput,
StageType,
Activity,
ActivityCreateInput,
ActivityUpdateInput,
ActivityFilters,
ActivityType,
ActivityState,
} from '../types';
// ============================================================================
// useLeads Hook
// ============================================================================
export interface UseLeadsOptions {
status?: LeadStatus;
stageId?: string;
userId?: string;
source?: LeadSource;
search?: string;
limit?: number;
autoFetch?: boolean;
}
export function useLeads(options: UseLeadsOptions = {}) {
const [leads, setLeads] = useState<Lead[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { status, stageId, userId, source, search, limit = 20, autoFetch = true } = options;
const fetchLeads = useCallback(async (pageNum = 1) => {
setIsLoading(true);
setError(null);
try {
const filters: LeadFilters = {
status,
stageId,
userId,
source,
search,
page: pageNum,
limit,
};
const response = await leadsApi.getAll(filters);
setLeads(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching leads'));
} finally {
setIsLoading(false);
}
}, [status, stageId, userId, source, search, limit]);
useEffect(() => {
if (autoFetch) {
fetchLeads(1);
}
}, [fetchLeads, autoFetch]);
const createLead = useCallback(async (data: LeadCreateInput): Promise<Lead> => {
const lead = await leadsApi.create(data);
await fetchLeads(page);
return lead;
}, [fetchLeads, page]);
const updateLead = useCallback(async (id: string, data: LeadUpdateInput): Promise<Lead> => {
const lead = await leadsApi.update(id, data);
await fetchLeads(page);
return lead;
}, [fetchLeads, page]);
const deleteLead = useCallback(async (id: string): Promise<void> => {
await leadsApi.delete(id);
await fetchLeads(page);
}, [fetchLeads, page]);
const convertLead = useCallback(async (id: string, data: LeadConvertInput): Promise<{ opportunityId?: string; partnerId?: string }> => {
const result = await leadsApi.convert(id, data);
await fetchLeads(page);
return result;
}, [fetchLeads, page]);
const markLeadLost = useCallback(async (id: string, reason?: string): Promise<Lead> => {
const lead = await leadsApi.markLost(id, reason);
await fetchLeads(page);
return lead;
}, [fetchLeads, page]);
return {
leads,
total,
page,
totalPages,
isLoading,
error,
setPage: (p: number) => fetchLeads(p),
refresh: () => fetchLeads(page),
createLead,
updateLead,
deleteLead,
convertLead,
markLeadLost,
};
}
// ============================================================================
// useOpportunities Hook
// ============================================================================
export interface UseOpportunitiesOptions {
status?: OpportunityStatus;
stageId?: string;
userId?: string;
partnerId?: string;
search?: string;
limit?: number;
autoFetch?: boolean;
}
export function useOpportunities(options: UseOpportunitiesOptions = {}) {
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { status, stageId, userId, partnerId, search, limit = 20, autoFetch = true } = options;
const fetchOpportunities = useCallback(async (pageNum = 1) => {
setIsLoading(true);
setError(null);
try {
const filters: OpportunityFilters = {
status,
stageId,
userId,
partnerId,
search,
page: pageNum,
limit,
};
const response = await opportunitiesApi.getAll(filters);
setOpportunities(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching opportunities'));
} finally {
setIsLoading(false);
}
}, [status, stageId, userId, partnerId, search, limit]);
useEffect(() => {
if (autoFetch) {
fetchOpportunities(1);
}
}, [fetchOpportunities, autoFetch]);
const createOpportunity = useCallback(async (data: OpportunityCreateInput): Promise<Opportunity> => {
const opp = await opportunitiesApi.create(data);
await fetchOpportunities(page);
return opp;
}, [fetchOpportunities, page]);
const updateOpportunity = useCallback(async (id: string, data: OpportunityUpdateInput): Promise<Opportunity> => {
const opp = await opportunitiesApi.update(id, data);
await fetchOpportunities(page);
return opp;
}, [fetchOpportunities, page]);
const deleteOpportunity = useCallback(async (id: string): Promise<void> => {
await opportunitiesApi.delete(id);
await fetchOpportunities(page);
}, [fetchOpportunities, page]);
const markWon = useCallback(async (id: string): Promise<Opportunity> => {
const opp = await opportunitiesApi.markWon(id);
await fetchOpportunities(page);
return opp;
}, [fetchOpportunities, page]);
const markLost = useCallback(async (id: string, reason?: string): Promise<Opportunity> => {
const opp = await opportunitiesApi.markLost(id, reason);
await fetchOpportunities(page);
return opp;
}, [fetchOpportunities, page]);
return {
opportunities,
total,
page,
totalPages,
isLoading,
error,
setPage: (p: number) => fetchOpportunities(p),
refresh: () => fetchOpportunities(page),
createOpportunity,
updateOpportunity,
deleteOpportunity,
markWon,
markLost,
};
}
// ============================================================================
// usePipeline Hook
// ============================================================================
export function usePipeline(companyId?: string) {
const [pipeline, setPipeline] = useState<Pipeline | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchPipeline = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await opportunitiesApi.getPipeline(companyId);
setPipeline(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching pipeline'));
} finally {
setIsLoading(false);
}
}, [companyId]);
useEffect(() => {
fetchPipeline();
}, [fetchPipeline]);
return {
pipeline,
isLoading,
error,
refresh: fetchPipeline,
};
}
// ============================================================================
// useStages Hook
// ============================================================================
export function useStages(type?: StageType, companyId?: string) {
const [stages, setStages] = useState<Stage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchStages = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await stagesApi.getAll(type, companyId);
setStages(response.data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching stages'));
} finally {
setIsLoading(false);
}
}, [type, companyId]);
useEffect(() => {
fetchStages();
}, [fetchStages]);
const createStage = useCallback(async (data: StageCreateInput): Promise<Stage> => {
const stage = await stagesApi.create(data);
await fetchStages();
return stage;
}, [fetchStages]);
const updateStage = useCallback(async (id: string, data: StageUpdateInput): Promise<Stage> => {
const stage = await stagesApi.update(id, data);
await fetchStages();
return stage;
}, [fetchStages]);
const deleteStage = useCallback(async (id: string): Promise<void> => {
await stagesApi.delete(id);
await fetchStages();
}, [fetchStages]);
const reorderStages = useCallback(async (stageIds: string[]): Promise<void> => {
await stagesApi.reorder(stageIds);
await fetchStages();
}, [fetchStages]);
return {
stages,
isLoading,
error,
refresh: fetchStages,
createStage,
updateStage,
deleteStage,
reorderStages,
};
}
// ============================================================================
// useActivities Hook
// ============================================================================
export interface UseActivitiesOptions {
type?: ActivityType;
state?: ActivityState;
userId?: string;
leadId?: string;
opportunityId?: string;
partnerId?: string;
search?: string;
limit?: number;
autoFetch?: boolean;
}
export function useActivities(options: UseActivitiesOptions = {}) {
const [activities, setActivities] = useState<Activity[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { type, state, userId, leadId, opportunityId, partnerId, search, limit = 20, autoFetch = true } = options;
const fetchActivities = useCallback(async (pageNum = 1) => {
setIsLoading(true);
setError(null);
try {
const filters: ActivityFilters = {
type,
state,
userId,
leadId,
opportunityId,
partnerId,
search,
page: pageNum,
limit,
};
const response = await activitiesApi.getAll(filters);
setActivities(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching activities'));
} finally {
setIsLoading(false);
}
}, [type, state, userId, leadId, opportunityId, partnerId, search, limit]);
useEffect(() => {
if (autoFetch) {
fetchActivities(1);
}
}, [fetchActivities, autoFetch]);
const createActivity = useCallback(async (data: ActivityCreateInput): Promise<Activity> => {
const activity = await activitiesApi.create(data);
await fetchActivities(page);
return activity;
}, [fetchActivities, page]);
const updateActivity = useCallback(async (id: string, data: ActivityUpdateInput): Promise<Activity> => {
const activity = await activitiesApi.update(id, data);
await fetchActivities(page);
return activity;
}, [fetchActivities, page]);
const deleteActivity = useCallback(async (id: string): Promise<void> => {
await activitiesApi.delete(id);
await fetchActivities(page);
}, [fetchActivities, page]);
const markActivityDone = useCallback(async (id: string): Promise<Activity> => {
const activity = await activitiesApi.markDone(id);
await fetchActivities(page);
return activity;
}, [fetchActivities, page]);
const cancelActivity = useCallback(async (id: string): Promise<Activity> => {
const activity = await activitiesApi.cancel(id);
await fetchActivities(page);
return activity;
}, [fetchActivities, page]);
return {
activities,
total,
page,
totalPages,
isLoading,
error,
setPage: (p: number) => fetchActivities(p),
refresh: () => fetchActivities(page),
createActivity,
updateActivity,
deleteActivity,
markActivityDone,
cancelActivity,
};
}

View File

@ -0,0 +1,3 @@
export * from './api/crm.api';
export * from './types';
export * from './hooks';

View File

@ -0,0 +1,300 @@
// CRM Types - Leads, Opportunities, Activities, Stages
// ============================================================================
// Lead Types
// ============================================================================
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
export interface Lead {
id: string;
tenantId: string;
companyId: string;
name: string;
contactName?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
street?: string;
city?: string;
state?: string;
country?: string;
stageId?: string;
stageName?: string;
status: LeadStatus;
userId?: string;
userName?: string;
teamId?: string;
source?: LeadSource;
medium?: string;
campaign?: string;
priority: number;
probability: number;
expectedRevenue?: number;
description?: string;
tags?: string[];
convertedOpportunityId?: string;
lostReason?: string;
createdAt: string;
updatedAt: string;
}
export interface LeadCreateInput {
name: string;
contactName?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
street?: string;
city?: string;
state?: string;
country?: string;
stageId?: string;
userId?: string;
teamId?: string;
source?: LeadSource;
medium?: string;
campaign?: string;
priority?: number;
probability?: number;
expectedRevenue?: number;
description?: string;
tags?: string[];
}
export interface LeadUpdateInput extends Partial<LeadCreateInput> {
status?: LeadStatus;
lostReason?: string;
}
export interface LeadFilters {
companyId?: string;
status?: LeadStatus;
stageId?: string;
userId?: string;
source?: LeadSource;
priority?: number;
search?: string;
page?: number;
limit?: number;
}
export interface LeadConvertInput {
createOpportunity?: boolean;
opportunityName?: string;
partnerId?: string;
createPartner?: boolean;
}
// ============================================================================
// Opportunity Types
// ============================================================================
export type OpportunityStatus = 'open' | 'won' | 'lost';
export interface Opportunity {
id: string;
tenantId: string;
companyId: string;
name: string;
partnerId: string;
partnerName?: string;
stageId?: string;
stageName?: string;
status: OpportunityStatus;
userId?: string;
userName?: string;
teamId?: string;
priority: number;
probability: number;
expectedRevenue?: number;
expectedCloseDate?: string;
quotationId?: string;
orderId?: string;
leadId?: string;
description?: string;
tags?: string[];
lostReason?: string;
wonDate?: string;
lostDate?: string;
createdAt: string;
updatedAt: string;
}
export interface OpportunityCreateInput {
name: string;
partnerId: string;
stageId?: string;
userId?: string;
teamId?: string;
priority?: number;
probability?: number;
expectedRevenue?: number;
expectedCloseDate?: string;
leadId?: string;
description?: string;
tags?: string[];
}
export interface OpportunityUpdateInput extends Partial<OpportunityCreateInput> {
status?: OpportunityStatus;
lostReason?: string;
}
export interface OpportunityFilters {
companyId?: string;
status?: OpportunityStatus;
stageId?: string;
userId?: string;
partnerId?: string;
priority?: number;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Stage Types
// ============================================================================
export type StageType = 'lead' | 'opportunity';
export interface Stage {
id: string;
tenantId: string;
companyId?: string;
name: string;
type: StageType;
sequence: number;
probability?: number;
isFolded?: boolean;
isWon?: boolean;
requirements?: string;
createdAt: string;
updatedAt: string;
}
export interface StageCreateInput {
name: string;
type: StageType;
sequence?: number;
probability?: number;
isFolded?: boolean;
isWon?: boolean;
requirements?: string;
}
export interface StageUpdateInput extends Partial<StageCreateInput> {}
// ============================================================================
// Activity Types
// ============================================================================
export type ActivityType = 'call' | 'meeting' | 'email' | 'task' | 'note';
export type ActivityState = 'planned' | 'done' | 'cancelled';
export interface Activity {
id: string;
tenantId: string;
companyId: string;
type: ActivityType;
summary: string;
description?: string;
dateDeadline?: string;
dateDone?: string;
state: ActivityState;
userId?: string;
userName?: string;
leadId?: string;
opportunityId?: string;
partnerId?: string;
createdAt: string;
updatedAt: string;
}
export interface ActivityCreateInput {
type: ActivityType;
summary: string;
description?: string;
dateDeadline?: string;
leadId?: string;
opportunityId?: string;
partnerId?: string;
}
export interface ActivityUpdateInput extends Partial<ActivityCreateInput> {
state?: ActivityState;
dateDone?: string;
}
export interface ActivityFilters {
companyId?: string;
type?: ActivityType;
state?: ActivityState;
userId?: string;
leadId?: string;
opportunityId?: string;
partnerId?: string;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Pipeline Types
// ============================================================================
export interface PipelineStage {
id: string;
name: string;
sequence: number;
probability: number;
count: number;
totalRevenue: number;
opportunities: Opportunity[];
}
export interface Pipeline {
stages: PipelineStage[];
totals: {
totalOpportunities: number;
totalRevenue: number;
weightedRevenue: number;
};
}
// ============================================================================
// Response Types
// ============================================================================
export interface LeadsResponse {
data: Lead[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface OpportunitiesResponse {
data: Opportunity[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface StagesResponse {
data: Stage[];
total: number;
}
export interface ActivitiesResponse {
data: Activity[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1 @@
export * from './crm.types';

View File

@ -0,0 +1,356 @@
import { api } from '@services/api/axios-instance';
import type {
Account,
CreateAccountDto,
UpdateAccountDto,
AccountFilters,
AccountsResponse,
AccountType,
Journal,
CreateJournalDto,
UpdateJournalDto,
JournalFilters,
JournalsResponse,
JournalEntry,
CreateJournalEntryDto,
UpdateJournalEntryDto,
JournalEntryFilters,
JournalEntriesResponse,
FinancialInvoice,
CreateInvoiceDto,
UpdateInvoiceDto,
InvoiceFilters,
InvoicesResponse,
Payment,
CreatePaymentDto,
UpdatePaymentDto,
PaymentFilters,
PaymentsResponse,
} from '../types';
const ACCOUNTS_URL = '/api/v1/financial/accounts';
const ACCOUNT_TYPES_URL = '/api/v1/financial/account-types';
const JOURNALS_URL = '/api/v1/financial/journals';
const ENTRIES_URL = '/api/v1/financial/journal-entries';
const INVOICES_URL = '/api/v1/financial/invoices';
const PAYMENTS_URL = '/api/v1/financial/payments';
export const financialApi = {
// ==================== Account Types ====================
// Get all account types
getAccountTypes: async (): Promise<AccountType[]> => {
const response = await api.get<AccountType[]>(ACCOUNT_TYPES_URL);
return response.data;
},
// ==================== Accounts ====================
// Get all accounts with filters
getAccounts: async (filters?: AccountFilters): Promise<AccountsResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.accountTypeId) params.append('accountTypeId', filters.accountTypeId);
if (filters?.parentId) params.append('parentId', filters.parentId);
if (filters?.isReconcilable !== undefined) params.append('isReconcilable', String(filters.isReconcilable));
if (filters?.isDeprecated !== undefined) params.append('isDeprecated', String(filters.isDeprecated));
if (filters?.search) params.append('search', filters.search);
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<AccountsResponse>(`${ACCOUNTS_URL}?${params.toString()}`);
return response.data;
},
// Get account by ID
getAccountById: async (id: string): Promise<Account> => {
const response = await api.get<Account>(`${ACCOUNTS_URL}/${id}`);
return response.data;
},
// Create account
createAccount: async (data: CreateAccountDto): Promise<Account> => {
const response = await api.post<Account>(ACCOUNTS_URL, data);
return response.data;
},
// Update account
updateAccount: async (id: string, data: UpdateAccountDto): Promise<Account> => {
const response = await api.patch<Account>(`${ACCOUNTS_URL}/${id}`, data);
return response.data;
},
// Delete account
deleteAccount: async (id: string): Promise<void> => {
await api.delete(`${ACCOUNTS_URL}/${id}`);
},
// Get accounts by type
getAccountsByType: async (accountTypeId: string, filters?: Omit<AccountFilters, 'accountTypeId'>): Promise<AccountsResponse> => {
return financialApi.getAccounts({ ...filters, accountTypeId });
},
// Get child accounts
getChildAccounts: async (parentId: string, filters?: Omit<AccountFilters, 'parentId'>): Promise<AccountsResponse> => {
return financialApi.getAccounts({ ...filters, parentId });
},
// ==================== Journals ====================
// Get all journals with filters
getJournals: async (filters?: JournalFilters): Promise<JournalsResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.journalType) params.append('journalType', filters.journalType);
if (filters?.active !== undefined) params.append('active', String(filters.active));
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
const response = await api.get<JournalsResponse>(`${JOURNALS_URL}?${params.toString()}`);
return response.data;
},
// Get journal by ID
getJournalById: async (id: string): Promise<Journal> => {
const response = await api.get<Journal>(`${JOURNALS_URL}/${id}`);
return response.data;
},
// Create journal
createJournal: async (data: CreateJournalDto): Promise<Journal> => {
const response = await api.post<Journal>(JOURNALS_URL, data);
return response.data;
},
// Update journal
updateJournal: async (id: string, data: UpdateJournalDto): Promise<Journal> => {
const response = await api.patch<Journal>(`${JOURNALS_URL}/${id}`, data);
return response.data;
},
// Delete journal
deleteJournal: async (id: string): Promise<void> => {
await api.delete(`${JOURNALS_URL}/${id}`);
},
// Get journals by type
getJournalsByType: async (journalType: string, filters?: Omit<JournalFilters, 'journalType'>): Promise<JournalsResponse> => {
return financialApi.getJournals({ ...filters, journalType: journalType as JournalFilters['journalType'] });
},
// ==================== Journal Entries ====================
// Get all journal entries with filters
getEntries: async (filters?: JournalEntryFilters): Promise<JournalEntriesResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.journalId) params.append('journalId', filters.journalId);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
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<JournalEntriesResponse>(`${ENTRIES_URL}?${params.toString()}`);
return response.data;
},
// Get journal entry by ID
getEntryById: async (id: string): Promise<JournalEntry> => {
const response = await api.get<JournalEntry>(`${ENTRIES_URL}/${id}`);
return response.data;
},
// Create journal entry
createEntry: async (data: CreateJournalEntryDto): Promise<JournalEntry> => {
const response = await api.post<JournalEntry>(ENTRIES_URL, data);
return response.data;
},
// Update journal entry
updateEntry: async (id: string, data: UpdateJournalEntryDto): Promise<JournalEntry> => {
const response = await api.patch<JournalEntry>(`${ENTRIES_URL}/${id}`, data);
return response.data;
},
// Delete journal entry
deleteEntry: async (id: string): Promise<void> => {
await api.delete(`${ENTRIES_URL}/${id}`);
},
// Post journal entry
postEntry: async (id: string): Promise<JournalEntry> => {
const response = await api.post<JournalEntry>(`${ENTRIES_URL}/${id}/post`);
return response.data;
},
// Cancel journal entry
cancelEntry: async (id: string): Promise<JournalEntry> => {
const response = await api.post<JournalEntry>(`${ENTRIES_URL}/${id}/cancel`);
return response.data;
},
// Get draft entries
getDraftEntries: async (filters?: Omit<JournalEntryFilters, 'status'>): Promise<JournalEntriesResponse> => {
return financialApi.getEntries({ ...filters, status: 'draft' });
},
// Get posted entries
getPostedEntries: async (filters?: Omit<JournalEntryFilters, 'status'>): Promise<JournalEntriesResponse> => {
return financialApi.getEntries({ ...filters, status: 'posted' });
},
// ==================== Invoices ====================
// Get all invoices with filters
getInvoices: async (filters?: InvoiceFilters): Promise<InvoicesResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.invoiceType) params.append('invoiceType', filters.invoiceType);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
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>(`${INVOICES_URL}?${params.toString()}`);
return response.data;
},
// Get invoice by ID
getInvoiceById: async (id: string): Promise<FinancialInvoice> => {
const response = await api.get<FinancialInvoice>(`${INVOICES_URL}/${id}`);
return response.data;
},
// Create invoice
createInvoice: async (data: CreateInvoiceDto): Promise<FinancialInvoice> => {
const response = await api.post<FinancialInvoice>(INVOICES_URL, data);
return response.data;
},
// Update invoice
updateInvoice: async (id: string, data: UpdateInvoiceDto): Promise<FinancialInvoice> => {
const response = await api.patch<FinancialInvoice>(`${INVOICES_URL}/${id}`, data);
return response.data;
},
// Delete invoice
deleteInvoice: async (id: string): Promise<void> => {
await api.delete(`${INVOICES_URL}/${id}`);
},
// Validate invoice (draft -> open)
validateInvoice: async (id: string): Promise<FinancialInvoice> => {
const response = await api.post<FinancialInvoice>(`${INVOICES_URL}/${id}/validate`);
return response.data;
},
// Cancel invoice
cancelInvoice: async (id: string): Promise<FinancialInvoice> => {
const response = await api.post<FinancialInvoice>(`${INVOICES_URL}/${id}/cancel`);
return response.data;
},
// Get customer invoices
getCustomerInvoices: async (filters?: Omit<InvoiceFilters, 'invoiceType'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, invoiceType: 'customer' });
},
// Get supplier invoices
getSupplierInvoices: async (filters?: Omit<InvoiceFilters, 'invoiceType'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, invoiceType: 'supplier' });
},
// Get open invoices
getOpenInvoices: async (filters?: Omit<InvoiceFilters, 'status'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, status: 'open' });
},
// Get invoices by partner
getInvoicesByPartner: async (partnerId: string, filters?: Omit<InvoiceFilters, 'partnerId'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, partnerId });
},
// ==================== Payments ====================
// Get all payments with filters
getPayments: async (filters?: PaymentFilters): Promise<PaymentsResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.paymentType) params.append('paymentType', filters.paymentType);
if (filters?.paymentMethod) params.append('paymentMethod', filters.paymentMethod);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
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<PaymentsResponse>(`${PAYMENTS_URL}?${params.toString()}`);
return response.data;
},
// Get payment by ID
getPaymentById: async (id: string): Promise<Payment> => {
const response = await api.get<Payment>(`${PAYMENTS_URL}/${id}`);
return response.data;
},
// Create payment
createPayment: async (data: CreatePaymentDto): Promise<Payment> => {
const response = await api.post<Payment>(PAYMENTS_URL, data);
return response.data;
},
// Update payment
updatePayment: async (id: string, data: UpdatePaymentDto): Promise<Payment> => {
const response = await api.patch<Payment>(`${PAYMENTS_URL}/${id}`, data);
return response.data;
},
// Delete payment
deletePayment: async (id: string): Promise<void> => {
await api.delete(`${PAYMENTS_URL}/${id}`);
},
// Post payment
postPayment: async (id: string): Promise<Payment> => {
const response = await api.post<Payment>(`${PAYMENTS_URL}/${id}/post`);
return response.data;
},
// Cancel payment
cancelPayment: async (id: string): Promise<Payment> => {
const response = await api.post<Payment>(`${PAYMENTS_URL}/${id}/cancel`);
return response.data;
},
// Get inbound payments (customer payments)
getInboundPayments: async (filters?: Omit<PaymentFilters, 'paymentType'>): Promise<PaymentsResponse> => {
return financialApi.getPayments({ ...filters, paymentType: 'inbound' });
},
// Get outbound payments (supplier payments)
getOutboundPayments: async (filters?: Omit<PaymentFilters, 'paymentType'>): Promise<PaymentsResponse> => {
return financialApi.getPayments({ ...filters, paymentType: 'outbound' });
},
// Get payments by partner
getPaymentsByPartner: async (partnerId: string, filters?: Omit<PaymentFilters, 'partnerId'>): Promise<PaymentsResponse> => {
return financialApi.getPayments({ ...filters, partnerId });
},
};

View File

@ -0,0 +1 @@
export { financialApi } from './financial.api';

View File

@ -0,0 +1,20 @@
export {
useAccountTypes,
useAccounts,
useAccount,
useJournals,
useJournalEntries,
useJournalEntry,
useInvoices,
useInvoice,
usePayments,
usePayment,
} from './useFinancial';
export type {
UseAccountsOptions,
UseJournalsOptions,
UseJournalEntriesOptions,
UseInvoicesOptions,
UsePaymentsOptions,
} from './useFinancial';

View File

@ -0,0 +1,749 @@
import { useState, useEffect, useCallback } from 'react';
import { financialApi } from '../api/financial.api';
import type {
Account,
AccountFilters,
CreateAccountDto,
UpdateAccountDto,
AccountType,
Journal,
JournalFilters,
CreateJournalDto,
UpdateJournalDto,
JournalEntry,
JournalEntryFilters,
CreateJournalEntryDto,
UpdateJournalEntryDto,
FinancialInvoice,
InvoiceFilters,
CreateInvoiceDto,
UpdateInvoiceDto,
Payment,
PaymentFilters,
CreatePaymentDto,
UpdatePaymentDto,
} from '../types';
// ==================== Account Types Hook ====================
export function useAccountTypes() {
const [accountTypes, setAccountTypes] = useState<AccountType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAccountTypes = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getAccountTypes();
setAccountTypes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar tipos de cuenta');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchAccountTypes();
}, [fetchAccountTypes]);
return {
accountTypes,
isLoading,
error,
refresh: fetchAccountTypes,
};
}
// ==================== Accounts Hook ====================
export interface UseAccountsOptions extends AccountFilters {
autoFetch?: boolean;
}
export function useAccounts(options: UseAccountsOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [accounts, setAccounts] = useState<Account[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAccounts = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getAccounts({ ...filters, page });
setAccounts(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar cuentas');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.accountTypeId, filters.parentId, filters.isReconcilable, filters.isDeprecated, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchAccounts();
}
}, [fetchAccounts, autoFetch]);
const createAccount = async (data: CreateAccountDto) => {
setIsLoading(true);
try {
const newAccount = await financialApi.createAccount(data);
await fetchAccounts();
return newAccount;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear cuenta');
throw err;
} finally {
setIsLoading(false);
}
};
const updateAccount = async (id: string, data: UpdateAccountDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateAccount(id, data);
await fetchAccounts();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar cuenta');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteAccount = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteAccount(id);
await fetchAccounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar cuenta');
throw err;
} finally {
setIsLoading(false);
}
};
return {
accounts,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchAccounts,
createAccount,
updateAccount,
deleteAccount,
};
}
// ==================== Single Account Hook ====================
export function useAccount(accountId: string | null) {
const [account, setAccount] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAccount = useCallback(async () => {
if (!accountId) {
setAccount(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getAccountById(accountId);
setAccount(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar cuenta');
} finally {
setIsLoading(false);
}
}, [accountId]);
useEffect(() => {
fetchAccount();
}, [fetchAccount]);
return {
account,
isLoading,
error,
refresh: fetchAccount,
};
}
// ==================== Journals Hook ====================
export interface UseJournalsOptions extends JournalFilters {
autoFetch?: boolean;
}
export function useJournals(options: UseJournalsOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [journals, setJournals] = useState<Journal[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchJournals = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getJournals({ ...filters, page });
setJournals(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar diarios');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.journalType, filters.active, filters.search, filters.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchJournals();
}
}, [fetchJournals, autoFetch]);
const createJournal = async (data: CreateJournalDto) => {
setIsLoading(true);
try {
const newJournal = await financialApi.createJournal(data);
await fetchJournals();
return newJournal;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear diario');
throw err;
} finally {
setIsLoading(false);
}
};
const updateJournal = async (id: string, data: UpdateJournalDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateJournal(id, data);
await fetchJournals();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar diario');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteJournal = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteJournal(id);
await fetchJournals();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar diario');
throw err;
} finally {
setIsLoading(false);
}
};
return {
journals,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchJournals,
createJournal,
updateJournal,
deleteJournal,
};
}
// ==================== Journal Entries Hook ====================
export interface UseJournalEntriesOptions extends JournalEntryFilters {
autoFetch?: boolean;
}
export function useJournalEntries(options: UseJournalEntriesOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [entries, setEntries] = useState<JournalEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEntries = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getEntries({ ...filters, page });
setEntries(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar asientos');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.journalId, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchEntries();
}
}, [fetchEntries, autoFetch]);
const createEntry = async (data: CreateJournalEntryDto) => {
setIsLoading(true);
try {
const newEntry = await financialApi.createEntry(data);
await fetchEntries();
return newEntry;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const updateEntry = async (id: string, data: UpdateJournalEntryDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateEntry(id, data);
await fetchEntries();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteEntry = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteEntry(id);
await fetchEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const postEntry = async (id: string) => {
setIsLoading(true);
try {
await financialApi.postEntry(id);
await fetchEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al publicar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelEntry = async (id: string) => {
setIsLoading(true);
try {
await financialApi.cancelEntry(id);
await fetchEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
return {
entries,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchEntries,
createEntry,
updateEntry,
deleteEntry,
postEntry,
cancelEntry,
};
}
// ==================== Single Journal Entry Hook ====================
export function useJournalEntry(entryId: string | null) {
const [entry, setEntry] = useState<JournalEntry | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEntry = useCallback(async () => {
if (!entryId) {
setEntry(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getEntryById(entryId);
setEntry(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar asiento');
} finally {
setIsLoading(false);
}
}, [entryId]);
useEffect(() => {
fetchEntry();
}, [fetchEntry]);
return {
entry,
isLoading,
error,
refresh: fetchEntry,
};
}
// ==================== Invoices Hook ====================
export interface UseInvoicesOptions extends InvoiceFilters {
autoFetch?: boolean;
}
export function useInvoices(options: UseInvoicesOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [invoices, setInvoices] = useState<FinancialInvoice[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInvoices = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getInvoices({ ...filters, page });
setInvoices(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar facturas');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.partnerId, filters.invoiceType, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchInvoices();
}
}, [fetchInvoices, autoFetch]);
const createInvoice = async (data: CreateInvoiceDto) => {
setIsLoading(true);
try {
const newInvoice = await financialApi.createInvoice(data);
await fetchInvoices();
return newInvoice;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear factura');
throw err;
} finally {
setIsLoading(false);
}
};
const updateInvoice = async (id: string, data: UpdateInvoiceDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateInvoice(id, data);
await fetchInvoices();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar factura');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteInvoice = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteInvoice(id);
await fetchInvoices();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar factura');
throw err;
} finally {
setIsLoading(false);
}
};
const validateInvoice = async (id: string) => {
setIsLoading(true);
try {
await financialApi.validateInvoice(id);
await fetchInvoices();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al validar factura');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelInvoice = async (id: string) => {
setIsLoading(true);
try {
await financialApi.cancelInvoice(id);
await fetchInvoices();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar factura');
throw err;
} finally {
setIsLoading(false);
}
};
return {
invoices,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchInvoices,
createInvoice,
updateInvoice,
deleteInvoice,
validateInvoice,
cancelInvoice,
};
}
// ==================== Single Invoice Hook ====================
export function useInvoice(invoiceId: string | null) {
const [invoice, setInvoice] = useState<FinancialInvoice | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInvoice = useCallback(async () => {
if (!invoiceId) {
setInvoice(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getInvoiceById(invoiceId);
setInvoice(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar factura');
} finally {
setIsLoading(false);
}
}, [invoiceId]);
useEffect(() => {
fetchInvoice();
}, [fetchInvoice]);
return {
invoice,
isLoading,
error,
refresh: fetchInvoice,
};
}
// ==================== Payments Hook ====================
export interface UsePaymentsOptions extends PaymentFilters {
autoFetch?: boolean;
}
export function usePayments(options: UsePaymentsOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [payments, setPayments] = useState<Payment[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPayments = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getPayments({ ...filters, page });
setPayments(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar pagos');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.partnerId, filters.paymentType, filters.paymentMethod, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchPayments();
}
}, [fetchPayments, autoFetch]);
const createPayment = async (data: CreatePaymentDto) => {
setIsLoading(true);
try {
const newPayment = await financialApi.createPayment(data);
await fetchPayments();
return newPayment;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear pago');
throw err;
} finally {
setIsLoading(false);
}
};
const updatePayment = async (id: string, data: UpdatePaymentDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updatePayment(id, data);
await fetchPayments();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar pago');
throw err;
} finally {
setIsLoading(false);
}
};
const deletePayment = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deletePayment(id);
await fetchPayments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar pago');
throw err;
} finally {
setIsLoading(false);
}
};
const postPayment = async (id: string) => {
setIsLoading(true);
try {
await financialApi.postPayment(id);
await fetchPayments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al publicar pago');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelPayment = async (id: string) => {
setIsLoading(true);
try {
await financialApi.cancelPayment(id);
await fetchPayments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar pago');
throw err;
} finally {
setIsLoading(false);
}
};
return {
payments,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchPayments,
createPayment,
updatePayment,
deletePayment,
postPayment,
cancelPayment,
};
}
// ==================== Single Payment Hook ====================
export function usePayment(paymentId: string | null) {
const [payment, setPayment] = useState<Payment | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPayment = useCallback(async () => {
if (!paymentId) {
setPayment(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getPaymentById(paymentId);
setPayment(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar pago');
} finally {
setIsLoading(false);
}
}, [paymentId]);
useEffect(() => {
fetchPayment();
}, [fetchPayment]);
return {
payment,
isLoading,
error,
refresh: fetchPayment,
};
}

View File

@ -0,0 +1,3 @@
export * from './api/financial.api';
export * from './types';
export * from './hooks';

View File

@ -0,0 +1,400 @@
// Account Types
export type AccountTypeEnum = 'asset' | 'liability' | 'equity' | 'income' | 'expense';
export interface AccountType {
id: string;
code: string;
name: string;
accountType: AccountTypeEnum;
description?: string | null;
}
export interface Account {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
code: string;
name: string;
accountTypeId: string;
accountTypeName?: string;
accountTypeCode?: string;
parentId: string | null;
parentName?: string;
currencyId: string | null;
currencyCode?: string;
isReconcilable: boolean;
isDeprecated: boolean;
notes: string | null;
balance?: number;
createdAt: string;
}
export interface CreateAccountDto {
companyId: string;
code: string;
name: string;
accountTypeId: string;
parentId?: string;
currencyId?: string;
isReconcilable?: boolean;
notes?: string;
}
export interface UpdateAccountDto {
name?: string;
accountTypeId?: string;
parentId?: string | null;
currencyId?: string | null;
isReconcilable?: boolean;
isDeprecated?: boolean;
notes?: string | null;
}
export interface AccountFilters {
companyId?: string;
accountTypeId?: string;
parentId?: string;
isReconcilable?: boolean;
isDeprecated?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface AccountsResponse {
data: Account[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Journal Types
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
export interface Journal {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
name: string;
code: string;
journalType: JournalType;
defaultAccountId: string | null;
defaultAccountName?: string;
sequenceId: string | null;
currencyId: string | null;
currencyCode?: string;
active: boolean;
createdAt: string;
}
export interface CreateJournalDto {
companyId: string;
name: string;
code: string;
journalType: JournalType;
defaultAccountId?: string;
currencyId?: string;
}
export interface UpdateJournalDto {
name?: string;
defaultAccountId?: string | null;
currencyId?: string | null;
active?: boolean;
}
export interface JournalFilters {
companyId?: string;
journalType?: JournalType;
active?: boolean;
search?: string;
page?: number;
limit?: number;
}
export interface JournalsResponse {
data: Journal[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Journal Entry Types
export type EntryStatus = 'draft' | 'posted' | 'cancelled';
export interface JournalEntryLine {
id: string;
entryId: string;
tenantId: string;
accountId: string;
accountCode?: string;
accountName?: string;
partnerId: string | null;
partnerName?: string;
debit: number;
credit: number;
description: string | null;
ref: string | null;
}
export interface JournalEntry {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
journalId: string;
journalName?: string;
journalCode?: string;
name: string;
ref: string | null;
date: string;
status: EntryStatus;
notes: string | null;
fiscalPeriodId: string | null;
lines?: JournalEntryLine[];
totalDebit?: number;
totalCredit?: number;
createdAt: string;
postedAt: string | null;
}
export interface CreateJournalEntryDto {
companyId: string;
journalId: string;
ref?: string;
date: string;
notes?: string;
lines: CreateJournalEntryLineDto[];
}
export interface CreateJournalEntryLineDto {
accountId: string;
partnerId?: string;
debit: number;
credit: number;
description?: string;
ref?: string;
}
export interface UpdateJournalEntryDto {
ref?: string | null;
date?: string;
notes?: string | null;
}
export interface JournalEntryFilters {
companyId?: string;
journalId?: string;
status?: EntryStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface JournalEntriesResponse {
data: JournalEntry[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Invoice Types
export type InvoiceType = 'customer' | 'supplier';
export type FinancialInvoiceStatus = 'draft' | 'open' | 'paid' | 'cancelled';
export interface InvoiceLine {
id: string;
invoiceId: string;
productId: string | null;
productName?: string;
accountId: string;
accountName?: string;
description: string;
quantity: number;
priceUnit: number;
discount: number;
amountUntaxed: number;
amountTax: number;
amountTotal: number;
}
export interface FinancialInvoice {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
partnerId: string;
partnerName?: string;
invoiceType: InvoiceType;
number: string | null;
ref: string | null;
invoiceDate: string;
dueDate: string | null;
currencyId: string;
currencyCode?: string;
amountUntaxed: number;
amountTax: number;
amountTotal: number;
amountPaid: number;
amountResidual: number;
status: FinancialInvoiceStatus;
paymentTermId: string | null;
journalId: string | null;
journalEntryId: string | null;
notes: string | null;
lines?: InvoiceLine[];
createdAt: string;
validatedAt: string | null;
}
export interface CreateInvoiceDto {
companyId: string;
partnerId: string;
invoiceType: InvoiceType;
ref?: string;
invoiceDate: string;
dueDate?: string;
currencyId: string;
paymentTermId?: string;
journalId?: string;
notes?: string;
lines?: CreateInvoiceLineDto[];
}
export interface CreateInvoiceLineDto {
productId?: string;
accountId: string;
description: string;
quantity: number;
priceUnit: number;
discount?: number;
}
export interface UpdateInvoiceDto {
partnerId?: string;
ref?: string | null;
invoiceDate?: string;
dueDate?: string | null;
currencyId?: string;
paymentTermId?: string | null;
journalId?: string | null;
notes?: string | null;
}
export interface InvoiceFilters {
companyId?: string;
partnerId?: string;
invoiceType?: InvoiceType;
status?: FinancialInvoiceStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface InvoicesResponse {
data: FinancialInvoice[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Payment Types
export type PaymentType = 'inbound' | 'outbound';
export type PaymentMethod = 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
export type PaymentStatus = 'draft' | 'posted' | 'reconciled' | 'cancelled';
export interface Payment {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
partnerId: string;
partnerName?: string;
paymentType: PaymentType;
paymentMethod: PaymentMethod;
amount: number;
currencyId: string;
currencyCode?: string;
paymentDate: string;
ref: string | null;
status: PaymentStatus;
journalId: string;
journalName?: string;
journalEntryId: string | null;
notes: string | null;
createdAt: string;
postedAt: string | null;
}
export interface CreatePaymentDto {
companyId: string;
partnerId: string;
paymentType: PaymentType;
paymentMethod: PaymentMethod;
amount: number;
currencyId: string;
paymentDate: string;
ref?: string;
journalId: string;
notes?: string;
}
export interface UpdatePaymentDto {
partnerId?: string;
paymentMethod?: PaymentMethod;
amount?: number;
currencyId?: string;
paymentDate?: string;
ref?: string | null;
journalId?: string;
notes?: string | null;
}
export interface PaymentFilters {
companyId?: string;
partnerId?: string;
paymentType?: PaymentType;
paymentMethod?: PaymentMethod;
status?: PaymentStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaymentsResponse {
data: Payment[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

View File

@ -0,0 +1 @@
export * from './financial.types';

View File

@ -0,0 +1,394 @@
import { api } from '@services/api/axios-instance';
import type {
StockLevel,
StockMovement,
StockSearchParams,
MovementSearchParams,
CreateStockMovementDto,
AdjustStockDto,
TransferStockDto,
ReserveStockDto,
StockLevelsResponse,
MovementsResponse,
Warehouse,
CreateWarehouseDto,
UpdateWarehouseDto,
WarehousesResponse,
Location,
CreateLocationDto,
UpdateLocationDto,
LocationsResponse,
InventoryCount,
InventoryCountLine,
CreateInventoryCountDto,
InventoryCountsResponse,
InventoryAdjustment,
InventoryAdjustmentLine,
CreateInventoryAdjustmentDto,
InventoryAdjustmentsResponse,
StockValuationLayer,
StockValuationsResponse,
ValuationSummaryResponse,
} from '../types';
const STOCK_URL = '/api/v1/inventory/stock';
const MOVEMENTS_URL = '/api/v1/inventory/movements';
const WAREHOUSES_URL = '/api/v1/warehouses';
const LOCATIONS_URL = '/api/v1/inventory/locations';
const COUNTS_URL = '/api/v1/inventory/counts';
const ADJUSTMENTS_URL = '/api/v1/inventory/adjustments';
const VALUATIONS_URL = '/api/v1/inventory/valuations';
export const inventoryApi = {
// ==================== Stock Levels ====================
// Get stock levels with filters
getStockLevels: async (params?: StockSearchParams): Promise<StockLevelsResponse> => {
const searchParams = new URLSearchParams();
if (params?.productId) searchParams.append('productId', params.productId);
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
if (params?.locationId) searchParams.append('locationId', params.locationId);
if (params?.lotNumber) searchParams.append('lotNumber', params.lotNumber);
if (params?.hasStock !== undefined) searchParams.append('hasStock', String(params.hasStock));
if (params?.lowStock !== undefined) searchParams.append('lowStock', String(params.lowStock));
if (params?.page) searchParams.append('page', String(params.page));
if (params?.limit) searchParams.append('limit', String(params.limit));
const response = await api.get<StockLevelsResponse>(`${STOCK_URL}?${searchParams.toString()}`);
return response.data;
},
// Get stock by product
getStockByProduct: async (productId: string): Promise<StockLevel[]> => {
const response = await api.get<StockLevel[]>(`${STOCK_URL}/product/${productId}`);
return response.data;
},
// Get stock by warehouse
getStockByWarehouse: async (warehouseId: string): Promise<StockLevel[]> => {
const response = await api.get<StockLevel[]>(`${STOCK_URL}/warehouse/${warehouseId}`);
return response.data;
},
// Get available stock for a product in a warehouse
getAvailableStock: async (productId: string, warehouseId: string): Promise<number> => {
const response = await api.get<{ available: number }>(`${STOCK_URL}/available/${productId}/${warehouseId}`);
return response.data.available;
},
// ==================== Stock Movements ====================
// Get movements with filters
getMovements: async (params?: MovementSearchParams): Promise<MovementsResponse> => {
const searchParams = new URLSearchParams();
if (params?.movementType) searchParams.append('movementType', params.movementType);
if (params?.productId) searchParams.append('productId', params.productId);
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
if (params?.status) searchParams.append('status', params.status);
if (params?.referenceType) searchParams.append('referenceType', params.referenceType);
if (params?.referenceId) searchParams.append('referenceId', params.referenceId);
if (params?.fromDate) searchParams.append('fromDate', params.fromDate);
if (params?.toDate) searchParams.append('toDate', params.toDate);
if (params?.page) searchParams.append('page', String(params.page));
if (params?.limit) searchParams.append('limit', String(params.limit));
const response = await api.get<MovementsResponse>(`${MOVEMENTS_URL}?${searchParams.toString()}`);
return response.data;
},
// Get movement by ID
getMovementById: async (id: string): Promise<StockMovement> => {
const response = await api.get<StockMovement>(`${MOVEMENTS_URL}/${id}`);
return response.data;
},
// Create movement
createMovement: async (data: CreateStockMovementDto): Promise<StockMovement> => {
const response = await api.post<StockMovement>(MOVEMENTS_URL, data);
return response.data;
},
// Confirm movement
confirmMovement: async (id: string): Promise<StockMovement> => {
const response = await api.post<StockMovement>(`${MOVEMENTS_URL}/${id}/confirm`);
return response.data;
},
// Cancel movement
cancelMovement: async (id: string): Promise<StockMovement> => {
const response = await api.post<StockMovement>(`${MOVEMENTS_URL}/${id}/cancel`);
return response.data;
},
// ==================== Stock Operations ====================
// Adjust stock
adjustStock: async (data: AdjustStockDto): Promise<StockMovement> => {
const response = await api.post<StockMovement>(`${STOCK_URL}/adjust`, data);
return response.data;
},
// Transfer stock between warehouses
transferStock: async (data: TransferStockDto): Promise<StockMovement> => {
const response = await api.post<StockMovement>(`${STOCK_URL}/transfer`, data);
return response.data;
},
// Reserve stock
reserveStock: async (data: ReserveStockDto): Promise<{ success: boolean }> => {
const response = await api.post<{ success: boolean }>(`${STOCK_URL}/reserve`, data);
return response.data;
},
// Release reservation
releaseReservation: async (productId: string, warehouseId: string, quantity: number): Promise<{ success: boolean }> => {
const response = await api.post<{ success: boolean }>(`${STOCK_URL}/release`, {
productId,
warehouseId,
quantity,
});
return response.data;
},
// ==================== Warehouses ====================
// Get all warehouses
getWarehouses: async (page?: number, limit?: number): Promise<WarehousesResponse> => {
const params = new URLSearchParams();
if (page) params.append('page', String(page));
if (limit) params.append('limit', String(limit));
const response = await api.get<WarehousesResponse>(`${WAREHOUSES_URL}?${params.toString()}`);
return response.data;
},
// Get warehouse by ID
getWarehouseById: async (id: string): Promise<Warehouse> => {
const response = await api.get<Warehouse>(`${WAREHOUSES_URL}/${id}`);
return response.data;
},
// Create warehouse
createWarehouse: async (data: CreateWarehouseDto): Promise<Warehouse> => {
const response = await api.post<Warehouse>(WAREHOUSES_URL, data);
return response.data;
},
// Update warehouse
updateWarehouse: async (id: string, data: UpdateWarehouseDto): Promise<Warehouse> => {
const response = await api.patch<Warehouse>(`${WAREHOUSES_URL}/${id}`, data);
return response.data;
},
// Delete warehouse
deleteWarehouse: async (id: string): Promise<void> => {
await api.delete(`${WAREHOUSES_URL}/${id}`);
},
// ==================== Locations ====================
// Get locations by warehouse
getLocationsByWarehouse: async (warehouseId: string): Promise<LocationsResponse> => {
const response = await api.get<LocationsResponse>(`${LOCATIONS_URL}?warehouseId=${warehouseId}`);
return response.data;
},
// Get location by ID
getLocationById: async (id: string): Promise<Location> => {
const response = await api.get<Location>(`${LOCATIONS_URL}/${id}`);
return response.data;
},
// Create location
createLocation: async (data: CreateLocationDto): Promise<Location> => {
const response = await api.post<Location>(LOCATIONS_URL, data);
return response.data;
},
// Update location
updateLocation: async (id: string, data: UpdateLocationDto): Promise<Location> => {
const response = await api.patch<Location>(`${LOCATIONS_URL}/${id}`, data);
return response.data;
},
// Delete location
deleteLocation: async (id: string): Promise<void> => {
await api.delete(`${LOCATIONS_URL}/${id}`);
},
// ==================== Inventory Counts ====================
// Get inventory counts
getInventoryCounts: async (params?: {
warehouseId?: string;
status?: string;
countType?: string;
page?: number;
limit?: number;
}): Promise<InventoryCountsResponse> => {
const searchParams = new URLSearchParams();
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
if (params?.status) searchParams.append('status', params.status);
if (params?.countType) searchParams.append('countType', params.countType);
if (params?.page) searchParams.append('page', String(params.page));
if (params?.limit) searchParams.append('limit', String(params.limit));
const response = await api.get<InventoryCountsResponse>(`${COUNTS_URL}?${searchParams.toString()}`);
return response.data;
},
// Get inventory count by ID
getInventoryCountById: async (id: string): Promise<InventoryCount> => {
const response = await api.get<InventoryCount>(`${COUNTS_URL}/${id}`);
return response.data;
},
// Get count lines
getInventoryCountLines: async (countId: string): Promise<InventoryCountLine[]> => {
const response = await api.get<InventoryCountLine[]>(`${COUNTS_URL}/${countId}/lines`);
return response.data;
},
// Create inventory count
createInventoryCount: async (data: CreateInventoryCountDto): Promise<InventoryCount> => {
const response = await api.post<InventoryCount>(COUNTS_URL, data);
return response.data;
},
// Start inventory count
startInventoryCount: async (id: string): Promise<InventoryCount> => {
const response = await api.post<InventoryCount>(`${COUNTS_URL}/${id}/start`);
return response.data;
},
// Update count line
updateCountLine: async (countId: string, lineId: string, data: { countedQuantity: number; notes?: string }): Promise<InventoryCountLine> => {
const response = await api.patch<InventoryCountLine>(`${COUNTS_URL}/${countId}/lines/${lineId}`, data);
return response.data;
},
// Complete inventory count
completeInventoryCount: async (id: string): Promise<InventoryCount> => {
const response = await api.post<InventoryCount>(`${COUNTS_URL}/${id}/complete`);
return response.data;
},
// Cancel inventory count
cancelInventoryCount: async (id: string): Promise<InventoryCount> => {
const response = await api.post<InventoryCount>(`${COUNTS_URL}/${id}/cancel`);
return response.data;
},
// ==================== Inventory Adjustments ====================
// Get inventory adjustments
getInventoryAdjustments: async (params?: {
locationId?: string;
status?: string;
fromDate?: string;
toDate?: string;
page?: number;
limit?: number;
}): Promise<InventoryAdjustmentsResponse> => {
const searchParams = new URLSearchParams();
if (params?.locationId) searchParams.append('locationId', params.locationId);
if (params?.status) searchParams.append('status', params.status);
if (params?.fromDate) searchParams.append('fromDate', params.fromDate);
if (params?.toDate) searchParams.append('toDate', params.toDate);
if (params?.page) searchParams.append('page', String(params.page));
if (params?.limit) searchParams.append('limit', String(params.limit));
const response = await api.get<InventoryAdjustmentsResponse>(`${ADJUSTMENTS_URL}?${searchParams.toString()}`);
return response.data;
},
// Get adjustment by ID
getInventoryAdjustmentById: async (id: string): Promise<InventoryAdjustment> => {
const response = await api.get<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}`);
return response.data;
},
// Get adjustment lines
getInventoryAdjustmentLines: async (adjustmentId: string): Promise<InventoryAdjustmentLine[]> => {
const response = await api.get<InventoryAdjustmentLine[]>(`${ADJUSTMENTS_URL}/${adjustmentId}/lines`);
return response.data;
},
// Create inventory adjustment
createInventoryAdjustment: async (data: CreateInventoryAdjustmentDto): Promise<InventoryAdjustment> => {
const response = await api.post<InventoryAdjustment>(ADJUSTMENTS_URL, data);
return response.data;
},
// Confirm adjustment
confirmInventoryAdjustment: async (id: string): Promise<InventoryAdjustment> => {
const response = await api.post<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}/confirm`);
return response.data;
},
// Apply adjustment (done)
applyInventoryAdjustment: async (id: string): Promise<InventoryAdjustment> => {
const response = await api.post<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}/apply`);
return response.data;
},
// Cancel adjustment
cancelInventoryAdjustment: async (id: string): Promise<InventoryAdjustment> => {
const response = await api.post<InventoryAdjustment>(`${ADJUSTMENTS_URL}/${id}/cancel`);
return response.data;
},
// ==================== Stock Valuations ====================
// Get valuation layers
getValuationLayers: async (params?: {
productId?: string;
warehouseId?: string;
fromDate?: string;
toDate?: string;
hasRemaining?: boolean;
page?: number;
limit?: number;
}): Promise<StockValuationsResponse> => {
const searchParams = new URLSearchParams();
if (params?.productId) searchParams.append('productId', params.productId);
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
if (params?.fromDate) searchParams.append('fromDate', params.fromDate);
if (params?.toDate) searchParams.append('toDate', params.toDate);
if (params?.hasRemaining !== undefined) searchParams.append('hasRemaining', String(params.hasRemaining));
if (params?.page) searchParams.append('page', String(params.page));
if (params?.limit) searchParams.append('limit', String(params.limit));
const response = await api.get<StockValuationsResponse>(`${VALUATIONS_URL}?${searchParams.toString()}`);
return response.data;
},
// Get valuation summary by product
getValuationSummary: async (params?: {
warehouseId?: string;
asOfDate?: string;
}): Promise<ValuationSummaryResponse> => {
const searchParams = new URLSearchParams();
if (params?.warehouseId) searchParams.append('warehouseId', params.warehouseId);
if (params?.asOfDate) searchParams.append('asOfDate', params.asOfDate);
const response = await api.get<ValuationSummaryResponse>(`${VALUATIONS_URL}/summary?${searchParams.toString()}`);
return response.data;
},
// Get valuation for specific product
getProductValuation: async (productId: string): Promise<{
totalQuantity: number;
totalValue: number;
averageCost: number;
layers: StockValuationLayer[];
}> => {
const response = await api.get<{
totalQuantity: number;
totalValue: number;
averageCost: number;
layers: StockValuationLayer[];
}>(`${VALUATIONS_URL}/product/${productId}`);
return response.data;
},
};

View File

@ -0,0 +1,16 @@
export {
useStockLevels,
useMovements,
useWarehouses,
useLocations,
useStockOperations,
useInventoryCounts,
useInventoryAdjustments,
} from './useInventory';
export type {
UseStockLevelsOptions,
UseMovementsOptions,
UseInventoryCountsOptions,
UseInventoryAdjustmentsOptions,
} from './useInventory';

View File

@ -0,0 +1,632 @@
import { useState, useEffect, useCallback } from 'react';
import { inventoryApi } from '../api/inventory.api';
import type {
StockLevel,
StockMovement,
StockSearchParams,
MovementSearchParams,
CreateStockMovementDto,
AdjustStockDto,
TransferStockDto,
Warehouse,
CreateWarehouseDto,
UpdateWarehouseDto,
Location,
CreateLocationDto,
UpdateLocationDto,
InventoryCount,
CreateInventoryCountDto,
CountType,
CountStatus,
InventoryAdjustment,
CreateInventoryAdjustmentDto,
AdjustmentStatus,
} from '../types';
// ==================== Stock Levels Hook ====================
export interface UseStockLevelsOptions extends StockSearchParams {
autoFetch?: boolean;
}
export function useStockLevels(options: UseStockLevelsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [stockLevels, setStockLevels] = useState<StockLevel[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStockLevels = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getStockLevels({ ...params, page });
setStockLevels(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar niveles de stock');
} finally {
setIsLoading(false);
}
}, [params.productId, params.warehouseId, params.locationId, params.lotNumber, params.hasStock, params.lowStock, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchStockLevels();
}
}, [fetchStockLevels, autoFetch]);
return {
stockLevels,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchStockLevels,
};
}
// ==================== Stock Movements Hook ====================
export interface UseMovementsOptions extends MovementSearchParams {
autoFetch?: boolean;
}
export function useMovements(options: UseMovementsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [movements, setMovements] = useState<StockMovement[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMovements = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getMovements({ ...params, page });
setMovements(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar movimientos');
} finally {
setIsLoading(false);
}
}, [params.movementType, params.productId, params.warehouseId, params.status, params.fromDate, params.toDate, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchMovements();
}
}, [fetchMovements, autoFetch]);
const createMovement = async (data: CreateStockMovementDto) => {
setIsLoading(true);
try {
const newMovement = await inventoryApi.createMovement(data);
await fetchMovements();
return newMovement;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const confirmMovement = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.confirmMovement(id);
await fetchMovements();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al confirmar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelMovement = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.cancelMovement(id);
await fetchMovements();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar movimiento');
throw err;
} finally {
setIsLoading(false);
}
};
return {
movements,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchMovements,
createMovement,
confirmMovement,
cancelMovement,
};
}
// ==================== Warehouses Hook ====================
export function useWarehouses() {
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchWarehouses = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getWarehouses(page, 20);
setWarehouses(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar almacenes');
} finally {
setIsLoading(false);
}
}, [page]);
useEffect(() => {
fetchWarehouses();
}, [fetchWarehouses]);
const createWarehouse = async (data: CreateWarehouseDto) => {
setIsLoading(true);
try {
const newWarehouse = await inventoryApi.createWarehouse(data);
await fetchWarehouses();
return newWarehouse;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear almacén');
throw err;
} finally {
setIsLoading(false);
}
};
const updateWarehouse = async (id: string, data: UpdateWarehouseDto) => {
setIsLoading(true);
try {
const updated = await inventoryApi.updateWarehouse(id, data);
await fetchWarehouses();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar almacén');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteWarehouse = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.deleteWarehouse(id);
await fetchWarehouses();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar almacén');
throw err;
} finally {
setIsLoading(false);
}
};
return {
warehouses,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchWarehouses,
createWarehouse,
updateWarehouse,
deleteWarehouse,
};
}
// ==================== Locations Hook ====================
export function useLocations(warehouseId?: string) {
const [locations, setLocations] = useState<Location[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchLocations = useCallback(async () => {
if (!warehouseId) {
setLocations([]);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getLocationsByWarehouse(warehouseId);
setLocations(response.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar ubicaciones');
} finally {
setIsLoading(false);
}
}, [warehouseId]);
useEffect(() => {
fetchLocations();
}, [fetchLocations]);
const createLocation = async (data: CreateLocationDto) => {
setIsLoading(true);
try {
const newLocation = await inventoryApi.createLocation(data);
await fetchLocations();
return newLocation;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear ubicación');
throw err;
} finally {
setIsLoading(false);
}
};
const updateLocation = async (id: string, data: UpdateLocationDto) => {
setIsLoading(true);
try {
const updated = await inventoryApi.updateLocation(id, data);
await fetchLocations();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar ubicación');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteLocation = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.deleteLocation(id);
await fetchLocations();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar ubicación');
throw err;
} finally {
setIsLoading(false);
}
};
return {
locations,
isLoading,
error,
refresh: fetchLocations,
createLocation,
updateLocation,
deleteLocation,
};
}
// ==================== Stock Operations Hook ====================
export function useStockOperations() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const adjustStock = async (data: AdjustStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.adjustStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al ajustar stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const transferStock = async (data: TransferStockDto) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.transferStock(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al transferir stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const reserveStock = async (productId: string, warehouseId: string, quantity: number, referenceType?: string, referenceId?: string) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.reserveStock({
productId,
warehouseId,
quantity,
referenceType,
referenceId,
});
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al reservar stock';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
const releaseReservation = async (productId: string, warehouseId: string, quantity: number) => {
setIsLoading(true);
setError(null);
try {
const result = await inventoryApi.releaseReservation(productId, warehouseId, quantity);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al liberar reserva';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return {
isLoading,
error,
adjustStock,
transferStock,
reserveStock,
releaseReservation,
};
}
// ==================== Inventory Counts Hook ====================
export interface UseInventoryCountsOptions {
warehouseId?: string;
status?: CountStatus;
countType?: CountType;
page?: number;
limit?: number;
autoFetch?: boolean;
}
export function useInventoryCounts(options: UseInventoryCountsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [counts, setCounts] = useState<InventoryCount[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchCounts = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getInventoryCounts({ ...params, page });
setCounts(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar conteos de inventario');
} finally {
setIsLoading(false);
}
}, [params.warehouseId, params.status, params.countType, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchCounts();
}
}, [fetchCounts, autoFetch]);
const createCount = async (data: CreateInventoryCountDto) => {
setIsLoading(true);
try {
const newCount = await inventoryApi.createInventoryCount(data);
await fetchCounts();
return newCount;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear conteo');
throw err;
} finally {
setIsLoading(false);
}
};
const startCount = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.startInventoryCount(id);
await fetchCounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al iniciar conteo');
throw err;
} finally {
setIsLoading(false);
}
};
const completeCount = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.completeInventoryCount(id);
await fetchCounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al completar conteo');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelCount = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.cancelInventoryCount(id);
await fetchCounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar conteo');
throw err;
} finally {
setIsLoading(false);
}
};
return {
counts,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchCounts,
createCount,
startCount,
completeCount,
cancelCount,
};
}
// ==================== Inventory Adjustments Hook ====================
export interface UseInventoryAdjustmentsOptions {
locationId?: string;
status?: AdjustmentStatus;
fromDate?: string;
toDate?: string;
page?: number;
limit?: number;
autoFetch?: boolean;
}
export function useInventoryAdjustments(options: UseInventoryAdjustmentsOptions = {}) {
const { autoFetch = true, ...params } = options;
const [adjustments, setAdjustments] = useState<InventoryAdjustment[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(params.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAdjustments = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inventoryApi.getInventoryAdjustments({ ...params, page });
setAdjustments(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar ajustes de inventario');
} finally {
setIsLoading(false);
}
}, [params.locationId, params.status, params.fromDate, params.toDate, params.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchAdjustments();
}
}, [fetchAdjustments, autoFetch]);
const createAdjustment = async (data: CreateInventoryAdjustmentDto) => {
setIsLoading(true);
try {
const newAdjustment = await inventoryApi.createInventoryAdjustment(data);
await fetchAdjustments();
return newAdjustment;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear ajuste');
throw err;
} finally {
setIsLoading(false);
}
};
const confirmAdjustment = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.confirmInventoryAdjustment(id);
await fetchAdjustments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al confirmar ajuste');
throw err;
} finally {
setIsLoading(false);
}
};
const applyAdjustment = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.applyInventoryAdjustment(id);
await fetchAdjustments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al aplicar ajuste');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelAdjustment = async (id: string) => {
setIsLoading(true);
try {
await inventoryApi.cancelInventoryAdjustment(id);
await fetchAdjustments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar ajuste');
throw err;
} finally {
setIsLoading(false);
}
};
return {
adjustments,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchAdjustments,
createAdjustment,
confirmAdjustment,
applyAdjustment,
cancelAdjustment,
};
}

View File

@ -0,0 +1,2 @@
export * from './api/inventory.api';
export * from './types';

View File

@ -0,0 +1 @@
export * from './inventory.types';

View File

@ -0,0 +1,377 @@
export type MovementType = 'receipt' | 'shipment' | 'transfer' | 'adjustment' | 'return' | 'production' | 'consumption';
export type MovementStatus = 'draft' | 'confirmed' | 'cancelled';
export interface StockLevel {
id: string;
tenantId: string;
productId: string;
warehouseId: string;
locationId?: string | null;
lotNumber?: string | null;
serialNumber?: string | null;
quantityOnHand: number;
quantityReserved: number;
quantityAvailable: number;
quantityIncoming: number;
quantityOutgoing: number;
unitCost?: number | null;
totalCost?: number | null;
lastMovementAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface StockMovement {
id: string;
tenantId: string;
movementNumber: string;
movementType: MovementType;
productId: string;
sourceWarehouseId?: string | null;
sourceLocationId?: string | null;
destWarehouseId?: string | null;
destLocationId?: string | null;
quantity: number;
unitCost?: number | null;
totalCost?: number | null;
lotNumber?: string | null;
serialNumber?: string | null;
expiryDate?: string | null;
reason?: string | null;
notes?: string | null;
referenceType?: string | null;
referenceId?: string | null;
status: MovementStatus;
confirmedAt?: string | null;
confirmedBy?: string | null;
createdBy?: string | null;
createdAt: string;
updatedAt: string;
}
export interface StockSearchParams {
productId?: string;
warehouseId?: string;
locationId?: string;
lotNumber?: string;
hasStock?: boolean;
lowStock?: boolean;
page?: number;
limit?: number;
}
export interface MovementSearchParams {
movementType?: MovementType;
productId?: string;
warehouseId?: string;
status?: MovementStatus;
referenceType?: string;
referenceId?: string;
fromDate?: string;
toDate?: string;
page?: number;
limit?: number;
}
export interface CreateStockMovementDto {
movementType: MovementType;
productId: string;
sourceWarehouseId?: string;
sourceLocationId?: string;
destWarehouseId?: string;
destLocationId?: string;
quantity: number;
unitCost?: number;
lotNumber?: string;
serialNumber?: string;
expiryDate?: string;
reason?: string;
notes?: string;
referenceType?: string;
referenceId?: string;
}
export interface AdjustStockDto {
productId: string;
warehouseId: string;
locationId?: string;
lotNumber?: string;
serialNumber?: string;
newQuantity: number;
reason?: string;
notes?: string;
}
export interface TransferStockDto {
productId: string;
sourceWarehouseId: string;
sourceLocationId?: string;
destWarehouseId: string;
destLocationId?: string;
quantity: number;
lotNumber?: string;
serialNumber?: string;
notes?: string;
}
export interface ReserveStockDto {
productId: string;
warehouseId: string;
locationId?: string;
lotNumber?: string;
quantity: number;
referenceType?: string;
referenceId?: string;
}
export interface StockLevelsResponse {
data: StockLevel[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface MovementsResponse {
data: StockMovement[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Warehouse types
export interface Warehouse {
id: string;
tenantId: string;
code: string;
name: string;
warehouseType: 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
address?: Record<string, any> | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateWarehouseDto {
code: string;
name: string;
warehouseType?: 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
address?: Record<string, any>;
}
export interface UpdateWarehouseDto {
code?: string;
name?: string;
warehouseType?: 'main' | 'transit' | 'customer' | 'supplier' | 'virtual';
address?: Record<string, any> | null;
isActive?: boolean;
}
export interface WarehousesResponse {
data: Warehouse[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Location types
export interface Location {
id: string;
tenantId: string;
warehouseId: string;
code: string;
name: string;
locationType: 'internal' | 'view' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
parentId?: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateLocationDto {
warehouseId: string;
code: string;
name: string;
locationType?: 'internal' | 'view' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
parentId?: string;
}
export interface UpdateLocationDto {
code?: string;
name?: string;
locationType?: 'internal' | 'view' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
parentId?: string | null;
isActive?: boolean;
}
export interface LocationsResponse {
data: Location[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Inventory Count types
export type CountType = 'full' | 'partial' | 'cycle' | 'spot';
export type CountStatus = 'draft' | 'in_progress' | 'completed' | 'cancelled';
export interface InventoryCount {
id: string;
tenantId: string;
warehouseId: string;
countNumber: string;
name?: string | null;
countType: CountType;
scheduledDate?: string | null;
startedAt?: string | null;
completedAt?: string | null;
status: CountStatus;
assignedTo?: string | null;
notes?: string | null;
createdAt: string;
createdBy?: string | null;
updatedAt: string;
}
export interface InventoryCountLine {
id: string;
countId: string;
productId: string;
locationId?: string | null;
systemQuantity?: number | null;
countedQuantity?: number | null;
lotNumber?: string | null;
serialNumber?: string | null;
isCounted: boolean;
countedAt?: string | null;
countedBy?: string | null;
notes?: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateInventoryCountDto {
warehouseId: string;
name?: string;
countType?: CountType;
scheduledDate?: string;
assignedTo?: string;
notes?: string;
}
export interface InventoryCountsResponse {
data: InventoryCount[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Inventory Adjustment types
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';
export interface InventoryAdjustment {
id: string;
tenantId: string;
companyId: string;
name: string;
locationId: string;
date: string;
status: AdjustmentStatus;
notes?: string | null;
createdAt: string;
createdBy?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
}
export interface InventoryAdjustmentLine {
id: string;
adjustmentId: string;
tenantId: string;
productId: string;
locationId: string;
lotId?: string | null;
theoreticalQty: number;
countedQty: number;
differenceQty: number;
uomId?: string | null;
notes?: string | null;
createdAt: string;
}
export interface CreateInventoryAdjustmentDto {
name: string;
locationId: string;
date?: string;
notes?: string;
}
export interface InventoryAdjustmentsResponse {
data: InventoryAdjustment[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Stock Valuation types
export interface StockValuationLayer {
id: string;
tenantId: string;
productId: string;
companyId: string;
quantity: number;
unitCost: number;
value: number;
remainingQty: number;
remainingValue: number;
stockMoveId?: string | null;
description?: string | null;
accountMoveId?: string | null;
journalEntryId?: string | null;
createdAt: string;
createdBy?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
}
export interface ValuationSummary {
productId: string;
productName?: string;
totalQuantity: number;
totalValue: number;
averageCost: number;
layerCount: number;
}
export interface StockValuationsResponse {
data: StockValuationLayer[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface ValuationSummaryResponse {
data: ValuationSummary[];
totalValue: number;
totalProducts: number;
}

View File

@ -0,0 +1,138 @@
import { api } from '@services/api/axios-instance';
import type {
Invoice,
CreateInvoiceDto,
UpdateInvoiceDto,
InvoiceFilters,
InvoicesResponse,
InvoiceStats,
} from '../types';
const BASE_URL = '/api/v1/invoices';
export const invoicesApi = {
// Get all invoices with filters
getAll: async (filters?: InvoiceFilters): Promise<InvoicesResponse> => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.invoiceType) params.append('invoiceType', filters.invoiceType);
if (filters?.invoiceContext) params.append('invoiceContext', filters.invoiceContext);
if (filters?.status) params.append('status', filters.status);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
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;
},
// Get invoice by ID
getById: async (id: string): Promise<Invoice> => {
const response = await api.get<Invoice>(`${BASE_URL}/${id}`);
return response.data;
},
// Create invoice
create: async (data: CreateInvoiceDto): Promise<Invoice> => {
const response = await api.post<Invoice>(BASE_URL, data);
return response.data;
},
// Update invoice
update: async (id: string, data: UpdateInvoiceDto): Promise<Invoice> => {
const response = await api.patch<Invoice>(`${BASE_URL}/${id}`, data);
return response.data;
},
// Delete invoice
delete: async (id: string): Promise<void> => {
await api.delete(`${BASE_URL}/${id}`);
},
// Validate invoice (change status from draft to validated)
validate: async (id: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`${BASE_URL}/${id}/validate`);
return response.data;
},
// Send invoice
send: async (id: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`${BASE_URL}/${id}/send`);
return response.data;
},
// Record payment
recordPayment: async (id: string, data: {
amount: number;
paymentMethod: string;
paymentReference?: string;
paymentDate?: string;
}): Promise<Invoice> => {
const response = await api.post<Invoice>(`${BASE_URL}/${id}/payment`, data);
return response.data;
},
// Cancel invoice
cancel: async (id: string, reason?: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`${BASE_URL}/${id}/cancel`, { reason });
return response.data;
},
// Void invoice
void: async (id: string, reason?: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`${BASE_URL}/${id}/void`, { reason });
return response.data;
},
// Get invoice stats
getStats: async (tenantId?: string): Promise<InvoiceStats> => {
const params = tenantId ? `?tenantId=${tenantId}` : '';
const response = await api.get<InvoiceStats>(`${BASE_URL}/stats${params}`);
return response.data;
},
// Generate PDF
generatePdf: async (id: string): Promise<Blob> => {
const response = await api.get(`${BASE_URL}/${id}/pdf`, {
responseType: 'blob',
});
return response.data;
},
// Download PDF
downloadPdf: async (id: string, fileName?: string): Promise<void> => {
const blob = await invoicesApi.generatePdf(id);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName || `invoice-${id}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
// Get sales invoices
getSales: async (filters?: Omit<InvoiceFilters, 'invoiceType'>): Promise<InvoicesResponse> => {
return invoicesApi.getAll({ ...filters, invoiceType: 'sale' });
},
// Get purchase invoices
getPurchases: async (filters?: Omit<InvoiceFilters, 'invoiceType'>): Promise<InvoicesResponse> => {
return invoicesApi.getAll({ ...filters, invoiceType: 'purchase' });
},
// Get draft invoices
getDrafts: async (filters?: Omit<InvoiceFilters, 'status'>): Promise<InvoicesResponse> => {
return invoicesApi.getAll({ ...filters, status: 'draft' });
},
// Get overdue invoices
getOverdue: async (filters?: Omit<InvoiceFilters, 'status'>): Promise<InvoicesResponse> => {
return invoicesApi.getAll({ ...filters, status: 'overdue' });
},
};

View File

@ -0,0 +1,2 @@
export * from './api/invoices.api';
export * from './types';

View File

@ -0,0 +1 @@
export * from './invoice.types';

View File

@ -0,0 +1,189 @@
export type InvoiceType = 'sale' | 'purchase' | 'credit_note' | 'debit_note';
export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided';
export type InvoiceContext = 'commercial' | 'saas';
export interface Invoice {
id: string;
tenantId: string;
invoiceNumber: string;
invoiceType: InvoiceType;
invoiceContext: InvoiceContext;
// Commercial fields
salesOrderId?: string | null;
purchaseOrderId?: string | null;
partnerId?: string | null;
partnerName?: string | null;
partnerTaxId?: string | null;
// SaaS fields
subscriptionId?: string | null;
periodStart?: string | null;
periodEnd?: string | null;
// Billing info
billingName?: string | null;
billingEmail?: string | null;
billingAddress?: Record<string, any> | null;
taxId?: string | null;
// Dates
invoiceDate: string;
dueDate?: string | null;
paymentDate?: string | null;
paidAt?: string | null;
// Amounts
currency: string;
exchangeRate: number;
subtotal: number;
taxAmount: number;
withholdingTax: number;
discountAmount: number;
total: number;
amountPaid: number;
amountDue?: number | null;
paidAmount: number;
// Payment
paymentTermDays: number;
paymentMethod?: string | null;
paymentReference?: string | null;
// Status
status: InvoiceStatus;
// CFDI
cfdiUuid?: string | null;
cfdiStatus?: string | null;
cfdiXml?: string | null;
cfdiPdfUrl?: string | null;
// Notes
notes?: string | null;
internalNotes?: string | null;
// Audit
createdAt: string;
createdBy?: string | null;
updatedAt: string;
updatedBy?: string | null;
deletedAt?: string | null;
// Relations
items?: InvoiceItem[];
}
export interface InvoiceItem {
id: string;
invoiceId: string;
description: string;
itemType: string;
quantity: number;
unitPrice: number;
subtotal: number;
profileCode?: string | null;
platform?: string | null;
periodStart?: string | null;
periodEnd?: string | null;
metadata?: Record<string, any>;
createdAt: string;
}
export interface CreateInvoiceDto {
invoiceType?: InvoiceType;
invoiceContext?: InvoiceContext;
// Commercial
partnerId?: string;
partnerName?: string;
partnerTaxId?: string;
salesOrderId?: string;
purchaseOrderId?: string;
// SaaS
subscriptionId?: string;
periodStart?: string;
periodEnd?: string;
// Billing
billingName?: string;
billingEmail?: string;
billingAddress?: Record<string, any>;
taxId?: string;
// Dates
invoiceDate?: string;
dueDate?: string;
// Amounts
currency?: string;
exchangeRate?: number;
paymentTermDays?: number;
// Notes
notes?: string;
internalNotes?: string;
// Items
items?: CreateInvoiceItemDto[];
}
export interface CreateInvoiceItemDto {
description: string;
itemType?: string;
quantity: number;
unitPrice: number;
metadata?: Record<string, any>;
}
export interface UpdateInvoiceDto {
partnerId?: string;
partnerName?: string;
partnerTaxId?: string;
billingName?: string;
billingEmail?: string;
billingAddress?: Record<string, any>;
taxId?: string;
invoiceDate?: string;
dueDate?: string | null;
currency?: string;
exchangeRate?: number;
paymentTermDays?: number;
paymentMethod?: string;
paymentReference?: string;
notes?: string | null;
internalNotes?: string | null;
}
export interface InvoiceFilters {
search?: string;
invoiceType?: InvoiceType;
invoiceContext?: InvoiceContext;
status?: InvoiceStatus;
partnerId?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface InvoicesResponse {
data: Invoice[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface InvoiceStats {
total: number;
byStatus: Record<InvoiceStatus, number>;
totalRevenue: number;
pendingAmount: number;
overdueAmount: number;
}

View File

@ -0,0 +1 @@
export * from './notifications.api';

View File

@ -0,0 +1,148 @@
import { api } from '@services/api/axios-instance';
import type {
NotificationChannel,
NotificationTemplate,
NotificationTemplateCreateInput,
NotificationPreference,
NotificationPreferenceUpdateInput,
Notification,
NotificationCreateInput,
NotificationStatus,
InAppNotification,
InAppNotificationCreateInput,
InAppNotificationsFilters,
InAppNotificationsResponse,
} from '../types';
const NOTIFICATIONS_BASE = '/api/v1/notifications';
// ============================================================================
// Channels API
// ============================================================================
export const channelsApi = {
getAll: async (): Promise<NotificationChannel[]> => {
const response = await api.get<NotificationChannel[]>(`${NOTIFICATIONS_BASE}/channels`);
return response.data;
},
getByCode: async (code: string): Promise<NotificationChannel> => {
const response = await api.get<NotificationChannel>(`${NOTIFICATIONS_BASE}/channels/${code}`);
return response.data;
},
};
// ============================================================================
// Templates API
// ============================================================================
export const templatesApi = {
getAll: async (): Promise<NotificationTemplate[]> => {
const response = await api.get<NotificationTemplate[]>(`${NOTIFICATIONS_BASE}/templates`);
return response.data;
},
getByCode: async (code: string): Promise<NotificationTemplate> => {
const response = await api.get<NotificationTemplate>(`${NOTIFICATIONS_BASE}/templates/${code}`);
return response.data;
},
create: async (data: NotificationTemplateCreateInput): Promise<NotificationTemplate> => {
const response = await api.post<NotificationTemplate>(`${NOTIFICATIONS_BASE}/templates`, data);
return response.data;
},
update: async (id: string, data: Partial<NotificationTemplateCreateInput>): Promise<NotificationTemplate> => {
const response = await api.patch<NotificationTemplate>(`${NOTIFICATIONS_BASE}/templates/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${NOTIFICATIONS_BASE}/templates/${id}`);
},
};
// ============================================================================
// Preferences API
// ============================================================================
export const preferencesApi = {
get: async (): Promise<NotificationPreference> => {
const response = await api.get<NotificationPreference>(`${NOTIFICATIONS_BASE}/preferences`);
return response.data;
},
update: async (data: NotificationPreferenceUpdateInput): Promise<NotificationPreference> => {
const response = await api.patch<NotificationPreference>(`${NOTIFICATIONS_BASE}/preferences`, data);
return response.data;
},
};
// ============================================================================
// Notifications API
// ============================================================================
export const notificationsApi = {
create: async (data: NotificationCreateInput): Promise<Notification> => {
const response = await api.post<Notification>(`${NOTIFICATIONS_BASE}`, data);
return response.data;
},
getPending: async (limit = 50): Promise<Notification[]> => {
const response = await api.get<Notification[]>(`${NOTIFICATIONS_BASE}/pending?limit=${limit}`);
return response.data;
},
updateStatus: async (id: string, status: NotificationStatus, errorMessage?: string): Promise<Notification> => {
const response = await api.patch<Notification>(`${NOTIFICATIONS_BASE}/${id}/status`, {
status,
errorMessage,
});
return response.data;
},
};
// ============================================================================
// In-App Notifications API
// ============================================================================
export const inAppApi = {
getAll: async (filters: InAppNotificationsFilters = {}): Promise<InAppNotificationsResponse> => {
const params = new URLSearchParams();
if (filters.includeRead !== undefined) params.append('include_read', String(filters.includeRead));
if (filters.category) params.append('category', filters.category);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<InAppNotificationsResponse>(`${NOTIFICATIONS_BASE}/in-app?${params}`);
return response.data;
},
getUnreadCount: async (): Promise<number> => {
const response = await api.get<{ count: number }>(`${NOTIFICATIONS_BASE}/in-app/unread-count`);
return response.data.count;
},
markAsRead: async (id: string): Promise<InAppNotification> => {
const response = await api.post<InAppNotification>(`${NOTIFICATIONS_BASE}/in-app/${id}/read`);
return response.data;
},
markAllAsRead: async (): Promise<void> => {
await api.post(`${NOTIFICATIONS_BASE}/in-app/read-all`);
},
create: async (data: InAppNotificationCreateInput): Promise<InAppNotification> => {
const response = await api.post<InAppNotification>(`${NOTIFICATIONS_BASE}/in-app`, data);
return response.data;
},
archive: async (id: string): Promise<InAppNotification> => {
const response = await api.post<InAppNotification>(`${NOTIFICATIONS_BASE}/in-app/${id}/archive`);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${NOTIFICATIONS_BASE}/in-app/${id}`);
},
};

View File

@ -0,0 +1,7 @@
export {
useChannels,
useTemplates,
useNotificationPreferences,
useInAppNotifications,
useNotificationBell,
} from './useNotifications';

View File

@ -0,0 +1,356 @@
import { useState, useEffect, useCallback } from 'react';
import {
channelsApi,
templatesApi,
preferencesApi,
inAppApi,
} from '../api';
import type {
NotificationChannel,
NotificationTemplate,
NotificationTemplateCreateInput,
NotificationPreference,
NotificationPreferenceUpdateInput,
InAppNotification,
InAppNotificationsFilters,
} from '../types';
// ============================================================================
// Channels Hook
// ============================================================================
interface UseChannelsReturn {
channels: NotificationChannel[];
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
getByCode: (code: string) => Promise<NotificationChannel>;
}
export function useChannels(): UseChannelsReturn {
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchChannels = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await channelsApi.getAll();
setChannels(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching channels'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const getByCode = useCallback(async (code: string): Promise<NotificationChannel> => {
return channelsApi.getByCode(code);
}, []);
return {
channels,
isLoading,
error,
refresh: fetchChannels,
getByCode,
};
}
// ============================================================================
// Templates Hook
// ============================================================================
interface UseTemplatesReturn {
templates: NotificationTemplate[];
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
getByCode: (code: string) => Promise<NotificationTemplate>;
create: (data: NotificationTemplateCreateInput) => Promise<NotificationTemplate>;
update: (id: string, data: Partial<NotificationTemplateCreateInput>) => Promise<NotificationTemplate>;
remove: (id: string) => Promise<void>;
}
export function useTemplates(): UseTemplatesReturn {
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchTemplates = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await templatesApi.getAll();
setTemplates(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching templates'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
const getByCode = useCallback(async (code: string): Promise<NotificationTemplate> => {
return templatesApi.getByCode(code);
}, []);
const create = useCallback(async (data: NotificationTemplateCreateInput): Promise<NotificationTemplate> => {
const result = await templatesApi.create(data);
await fetchTemplates();
return result;
}, [fetchTemplates]);
const update = useCallback(async (id: string, data: Partial<NotificationTemplateCreateInput>): Promise<NotificationTemplate> => {
const result = await templatesApi.update(id, data);
await fetchTemplates();
return result;
}, [fetchTemplates]);
const remove = useCallback(async (id: string): Promise<void> => {
await templatesApi.delete(id);
await fetchTemplates();
}, [fetchTemplates]);
return {
templates,
isLoading,
error,
refresh: fetchTemplates,
getByCode,
create,
update,
remove,
};
}
// ============================================================================
// Preferences Hook
// ============================================================================
interface UseNotificationPreferencesReturn {
preferences: NotificationPreference | null;
isLoading: boolean;
isSaving: boolean;
error: Error | null;
refresh: () => Promise<void>;
update: (data: NotificationPreferenceUpdateInput) => Promise<NotificationPreference>;
}
export function useNotificationPreferences(): UseNotificationPreferencesReturn {
const [preferences, setPreferences] = useState<NotificationPreference | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchPreferences = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await preferencesApi.get();
setPreferences(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching preferences'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchPreferences();
}, [fetchPreferences]);
const update = useCallback(async (data: NotificationPreferenceUpdateInput): Promise<NotificationPreference> => {
setIsSaving(true);
setError(null);
try {
const result = await preferencesApi.update(data);
setPreferences(result);
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Error updating preferences');
setError(error);
throw error;
} finally {
setIsSaving(false);
}
}, []);
return {
preferences,
isLoading,
isSaving,
error,
refresh: fetchPreferences,
update,
};
}
// ============================================================================
// In-App Notifications Hook
// ============================================================================
interface UseInAppNotificationsOptions {
initialFilters?: InAppNotificationsFilters;
autoLoad?: boolean;
pollInterval?: number;
}
interface UseInAppNotificationsReturn {
notifications: InAppNotification[];
unreadCount: number;
total: number;
page: number;
totalPages: number;
isLoading: boolean;
error: Error | null;
filters: InAppNotificationsFilters;
setFilters: (filters: InAppNotificationsFilters) => void;
refresh: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
archive: (id: string) => Promise<void>;
remove: (id: string) => Promise<void>;
}
export function useInAppNotifications(
options: UseInAppNotificationsOptions = {}
): UseInAppNotificationsReturn {
const { initialFilters = {}, autoLoad = true, pollInterval } = options;
const [notifications, setNotifications] = useState<InAppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<InAppNotificationsFilters>(initialFilters);
const fetchNotifications = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await inAppApi.getAll(filters);
setNotifications(response.data);
setUnreadCount(response.unreadCount);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching notifications'));
} finally {
setIsLoading(false);
}
}, [filters]);
const fetchUnreadCount = useCallback(async () => {
try {
const count = await inAppApi.getUnreadCount();
setUnreadCount(count);
} catch (err) {
// Silently fail for unread count polling
}
}, []);
useEffect(() => {
if (autoLoad) {
fetchNotifications();
}
}, [autoLoad, fetchNotifications]);
// Poll for unread count
useEffect(() => {
if (!pollInterval) return;
const interval = setInterval(fetchUnreadCount, pollInterval);
return () => clearInterval(interval);
}, [pollInterval, fetchUnreadCount]);
const markAsRead = useCallback(async (id: string): Promise<void> => {
await inAppApi.markAsRead(id);
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, isRead: true, readAt: new Date().toISOString() } : n)
);
setUnreadCount(prev => Math.max(0, prev - 1));
}, []);
const markAllAsRead = useCallback(async (): Promise<void> => {
await inAppApi.markAllAsRead();
setNotifications(prev =>
prev.map(n => ({ ...n, isRead: true, readAt: new Date().toISOString() }))
);
setUnreadCount(0);
}, []);
const archive = useCallback(async (id: string): Promise<void> => {
await inAppApi.archive(id);
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await inAppApi.delete(id);
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
return {
notifications,
unreadCount,
total,
page,
totalPages,
isLoading,
error,
filters,
setFilters,
refresh: fetchNotifications,
markAsRead,
markAllAsRead,
archive,
remove,
};
}
// ============================================================================
// Notification Bell Hook (for header/navbar)
// ============================================================================
interface UseNotificationBellReturn {
unreadCount: number;
recentNotifications: InAppNotification[];
isLoading: boolean;
refresh: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
}
export function useNotificationBell(pollInterval = 30000): UseNotificationBellReturn {
const {
notifications: recentNotifications,
unreadCount,
isLoading,
refresh,
markAsRead,
markAllAsRead,
} = useInAppNotifications({
initialFilters: { limit: 5, includeRead: false },
pollInterval,
});
return {
unreadCount,
recentNotifications,
isLoading,
refresh,
markAsRead,
markAllAsRead,
};
}

View File

@ -0,0 +1,10 @@
// Notifications Feature - Barrel Export
// Types
export * from './types';
// API
export * from './api';
// Hooks
export * from './hooks';

View File

@ -0,0 +1 @@
export * from './notifications.types';

View File

@ -0,0 +1,293 @@
// Notifications Types - Channels, Templates, Preferences, In-App
// ============================================================================
// Channel Types
// ============================================================================
export type ChannelType = 'email' | 'sms' | 'push' | 'whatsapp' | 'in_app' | 'webhook';
export interface NotificationChannel {
id: string;
tenantId: string;
code: string;
name: string;
type: ChannelType;
isActive: boolean;
providerConfig?: Record<string, any>;
rateLimitPerMinute?: number;
rateLimitPerHour?: number;
createdAt: string;
updatedAt: string;
}
// ============================================================================
// Template Types
// ============================================================================
export interface NotificationTemplate {
id: string;
tenantId: string;
code: string;
name: string;
description?: string;
channelType: ChannelType;
subject?: string;
body: string;
variables: string[];
isActive: boolean;
translations?: TemplateTranslation[];
createdAt: string;
updatedAt: string;
}
export interface TemplateTranslation {
id: string;
templateId: string;
locale: string;
subject?: string;
body: string;
}
export interface NotificationTemplateCreateInput {
code: string;
name: string;
description?: string;
channelType: ChannelType;
subject?: string;
body: string;
variables?: string[];
}
export interface NotificationTemplateUpdateInput extends Partial<NotificationTemplateCreateInput> {
isActive?: boolean;
}
// ============================================================================
// Preference Types
// ============================================================================
export interface NotificationPreference {
id: string;
userId: string;
tenantId: string;
emailEnabled: boolean;
smsEnabled: boolean;
pushEnabled: boolean;
whatsappEnabled: boolean;
inAppEnabled: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
digestEnabled: boolean;
digestFrequency?: 'daily' | 'weekly';
categoryPreferences?: Record<string, boolean>;
createdAt: string;
updatedAt: string;
}
export interface NotificationPreferenceUpdateInput {
emailEnabled?: boolean;
smsEnabled?: boolean;
pushEnabled?: boolean;
whatsappEnabled?: boolean;
inAppEnabled?: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
digestEnabled?: boolean;
digestFrequency?: 'daily' | 'weekly';
categoryPreferences?: Record<string, boolean>;
}
// ============================================================================
// Notification Types
// ============================================================================
export type NotificationStatus =
| 'pending'
| 'queued'
| 'sending'
| 'sent'
| 'delivered'
| 'read'
| 'failed'
| 'cancelled';
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
export interface Notification {
id: string;
tenantId: string;
userId: string;
templateId?: string;
channelType: ChannelType;
recipient: string;
subject?: string;
body: string;
status: NotificationStatus;
priority: NotificationPriority;
scheduledAt?: string;
sentAt?: string;
deliveredAt?: string;
readAt?: string;
failedAt?: string;
retryCount: number;
maxRetries: number;
lastError?: string;
metadata?: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface NotificationCreateInput {
userId: string;
templateCode?: string;
channelType: ChannelType;
recipient: string;
subject?: string;
body?: string;
variables?: Record<string, any>;
priority?: NotificationPriority;
scheduledAt?: string;
metadata?: Record<string, any>;
}
// ============================================================================
// In-App Notification Types
// ============================================================================
export type InAppCategory =
| 'system'
| 'order'
| 'payment'
| 'inventory'
| 'user'
| 'alert'
| 'reminder'
| 'other';
export interface InAppNotification {
id: string;
tenantId: string;
userId: string;
title: string;
message: string;
category: InAppCategory;
priority: NotificationPriority;
isRead: boolean;
isArchived: boolean;
actionUrl?: string;
actionLabel?: string;
icon?: string;
imageUrl?: string;
metadata?: Record<string, any>;
expiresAt?: string;
readAt?: string;
createdAt: string;
}
export interface InAppNotificationCreateInput {
userId: string;
title: string;
message: string;
category?: InAppCategory;
priority?: NotificationPriority;
actionUrl?: string;
actionLabel?: string;
icon?: string;
imageUrl?: string;
metadata?: Record<string, any>;
expiresAt?: string;
}
export interface InAppNotificationsFilters {
includeRead?: boolean;
category?: InAppCategory;
page?: number;
limit?: number;
}
export interface InAppNotificationsResponse {
data: InAppNotification[];
total: number;
page: number;
limit: number;
totalPages: number;
unreadCount: number;
}
// ============================================================================
// Batch Notification Types
// ============================================================================
export type BatchStatus = 'draft' | 'scheduled' | 'sending' | 'completed' | 'failed' | 'cancelled';
export interface NotificationBatch {
id: string;
tenantId: string;
name: string;
templateId: string;
channelType: ChannelType;
status: BatchStatus;
audienceFilter?: Record<string, any>;
scheduledAt?: string;
startedAt?: string;
completedAt?: string;
totalCount: number;
sentCount: number;
deliveredCount: number;
failedCount: number;
readCount: number;
createdBy: string;
createdAt: string;
updatedAt: string;
}
// ============================================================================
// Constants and Labels
// ============================================================================
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
email: 'Correo electronico',
sms: 'SMS',
push: 'Notificacion push',
whatsapp: 'WhatsApp',
in_app: 'En la aplicacion',
webhook: 'Webhook',
};
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
pending: 'Pendiente',
queued: 'En cola',
sending: 'Enviando',
sent: 'Enviado',
delivered: 'Entregado',
read: 'Leido',
failed: 'Fallido',
cancelled: 'Cancelado',
};
export const NOTIFICATION_PRIORITY_LABELS: Record<NotificationPriority, string> = {
low: 'Baja',
normal: 'Normal',
high: 'Alta',
urgent: 'Urgente',
};
export const IN_APP_CATEGORY_LABELS: Record<InAppCategory, string> = {
system: 'Sistema',
order: 'Pedidos',
payment: 'Pagos',
inventory: 'Inventario',
user: 'Usuarios',
alert: 'Alertas',
reminder: 'Recordatorios',
other: 'Otros',
};
export const BATCH_STATUS_LABELS: Record<BatchStatus, string> = {
draft: 'Borrador',
scheduled: 'Programado',
sending: 'Enviando',
completed: 'Completado',
failed: 'Fallido',
cancelled: 'Cancelado',
};

View File

@ -0,0 +1 @@
export * from './projects.api';

View File

@ -0,0 +1,245 @@
import { api } from '@services/api/axios-instance';
import type {
Project,
ProjectCreateInput,
ProjectUpdateInput,
ProjectFilters,
ProjectsResponse,
ProjectStats,
Task,
TaskCreateInput,
TaskUpdateInput,
TaskFilters,
TasksResponse,
TaskBoard,
TaskStage,
TaskStageCreateInput,
TaskStageUpdateInput,
TaskStagesResponse,
Timesheet,
TimesheetCreateInput,
TimesheetUpdateInput,
TimesheetFilters,
TimesheetsResponse,
} from '../types';
const PROJECTS_BASE = '/api/projects';
// ============================================================================
// Projects API
// ============================================================================
export const projectsApi = {
getAll: async (filters: ProjectFilters = {}): Promise<ProjectsResponse> => {
const params = new URLSearchParams();
if (filters.companyId) params.append('company_id', filters.companyId);
if (filters.managerId) params.append('manager_id', filters.managerId);
if (filters.partnerId) params.append('partner_id', filters.partnerId);
if (filters.status) params.append('status', filters.status);
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<ProjectsResponse>(`${PROJECTS_BASE}?${params}`);
return response.data;
},
getById: async (id: string): Promise<Project> => {
const response = await api.get<Project>(`${PROJECTS_BASE}/${id}`);
return response.data;
},
create: async (data: ProjectCreateInput): Promise<Project> => {
const response = await api.post<Project>(PROJECTS_BASE, data);
return response.data;
},
update: async (id: string, data: ProjectUpdateInput): Promise<Project> => {
const response = await api.patch<Project>(`${PROJECTS_BASE}/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${PROJECTS_BASE}/${id}`);
},
getStats: async (id: string): Promise<ProjectStats> => {
const response = await api.get<ProjectStats>(`${PROJECTS_BASE}/${id}/stats`);
return response.data;
},
activate: async (id: string): Promise<Project> => {
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/activate`);
return response.data;
},
complete: async (id: string): Promise<Project> => {
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/complete`);
return response.data;
},
cancel: async (id: string): Promise<Project> => {
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/cancel`);
return response.data;
},
hold: async (id: string): Promise<Project> => {
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/hold`);
return response.data;
},
};
// ============================================================================
// Tasks API
// ============================================================================
export const tasksApi = {
getAll: async (filters: TaskFilters = {}): Promise<TasksResponse> => {
const params = new URLSearchParams();
if (filters.projectId) params.append('project_id', filters.projectId);
if (filters.assignedTo) params.append('assigned_to', filters.assignedTo);
if (filters.status) params.append('status', filters.status);
if (filters.priority) params.append('priority', filters.priority);
if (filters.parentId) params.append('parent_id', filters.parentId);
if (filters.stageId) params.append('stage_id', filters.stageId);
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<TasksResponse>(`${PROJECTS_BASE}/tasks?${params}`);
return response.data;
},
getById: async (id: string): Promise<Task> => {
const response = await api.get<Task>(`${PROJECTS_BASE}/tasks/${id}`);
return response.data;
},
create: async (data: TaskCreateInput): Promise<Task> => {
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks`, data);
return response.data;
},
update: async (id: string, data: TaskUpdateInput): Promise<Task> => {
const response = await api.patch<Task>(`${PROJECTS_BASE}/tasks/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${PROJECTS_BASE}/tasks/${id}`);
},
getBoard: async (projectId: string): Promise<TaskBoard> => {
const response = await api.get<TaskBoard>(`${PROJECTS_BASE}/${projectId}/board`);
return response.data;
},
moveToStage: async (taskId: string, stageId: string, sequence?: number): Promise<Task> => {
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${taskId}/move`, {
stageId,
sequence,
});
return response.data;
},
start: async (id: string): Promise<Task> => {
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${id}/start`);
return response.data;
},
complete: async (id: string): Promise<Task> => {
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${id}/complete`);
return response.data;
},
cancel: async (id: string): Promise<Task> => {
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${id}/cancel`);
return response.data;
},
};
// ============================================================================
// Task Stages API
// ============================================================================
export const taskStagesApi = {
getAll: async (projectId?: string): Promise<TaskStagesResponse> => {
const params = projectId ? `?project_id=${projectId}` : '';
const response = await api.get<TaskStagesResponse>(`${PROJECTS_BASE}/stages${params}`);
return response.data;
},
getById: async (id: string): Promise<TaskStage> => {
const response = await api.get<TaskStage>(`${PROJECTS_BASE}/stages/${id}`);
return response.data;
},
create: async (data: TaskStageCreateInput): Promise<TaskStage> => {
const response = await api.post<TaskStage>(`${PROJECTS_BASE}/stages`, data);
return response.data;
},
update: async (id: string, data: TaskStageUpdateInput): Promise<TaskStage> => {
const response = await api.patch<TaskStage>(`${PROJECTS_BASE}/stages/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${PROJECTS_BASE}/stages/${id}`);
},
reorder: async (stageIds: string[]): Promise<void> => {
await api.post(`${PROJECTS_BASE}/stages/reorder`, { stageIds });
},
};
// ============================================================================
// Timesheets API
// ============================================================================
export const timesheetsApi = {
getAll: async (filters: TimesheetFilters = {}): Promise<TimesheetsResponse> => {
const params = new URLSearchParams();
if (filters.companyId) params.append('company_id', filters.companyId);
if (filters.projectId) params.append('project_id', filters.projectId);
if (filters.taskId) params.append('task_id', filters.taskId);
if (filters.employeeId) params.append('employee_id', filters.employeeId);
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
if (filters.dateTo) params.append('date_to', filters.dateTo);
if (filters.isBillable !== undefined) params.append('is_billable', String(filters.isBillable));
if (filters.isBilled !== undefined) params.append('is_billed', String(filters.isBilled));
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<TimesheetsResponse>(`${PROJECTS_BASE}/timesheets?${params}`);
return response.data;
},
getById: async (id: string): Promise<Timesheet> => {
const response = await api.get<Timesheet>(`${PROJECTS_BASE}/timesheets/${id}`);
return response.data;
},
create: async (data: TimesheetCreateInput): Promise<Timesheet> => {
const response = await api.post<Timesheet>(`${PROJECTS_BASE}/timesheets`, data);
return response.data;
},
update: async (id: string, data: TimesheetUpdateInput): Promise<Timesheet> => {
const response = await api.patch<Timesheet>(`${PROJECTS_BASE}/timesheets/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${PROJECTS_BASE}/timesheets/${id}`);
},
getByProject: async (projectId: string, filters: Omit<TimesheetFilters, 'projectId'> = {}): Promise<TimesheetsResponse> => {
return timesheetsApi.getAll({ ...filters, projectId });
},
getByEmployee: async (employeeId: string, filters: Omit<TimesheetFilters, 'employeeId'> = {}): Promise<TimesheetsResponse> => {
return timesheetsApi.getAll({ ...filters, employeeId });
},
};

View File

@ -0,0 +1 @@
export * from './useProjects';

View File

@ -0,0 +1,499 @@
import { useState, useEffect, useCallback } from 'react';
import { projectsApi, tasksApi, taskStagesApi, timesheetsApi } from '../api';
import type {
Project,
ProjectCreateInput,
ProjectUpdateInput,
ProjectFilters,
ProjectStatus,
ProjectStats,
Task,
TaskCreateInput,
TaskUpdateInput,
TaskFilters,
TaskStatus,
TaskPriority,
TaskBoard,
TaskStage,
TaskStageCreateInput,
TaskStageUpdateInput,
Timesheet,
TimesheetCreateInput,
TimesheetUpdateInput,
TimesheetFilters,
} from '../types';
// ============================================================================
// useProjects Hook
// ============================================================================
export interface UseProjectsOptions {
managerId?: string;
partnerId?: string;
status?: ProjectStatus;
search?: string;
limit?: number;
autoFetch?: boolean;
}
export function useProjects(options: UseProjectsOptions = {}) {
const [projects, setProjects] = useState<Project[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { managerId, partnerId, status, search, limit = 20, autoFetch = true } = options;
const fetchProjects = useCallback(async (pageNum = 1) => {
setIsLoading(true);
setError(null);
try {
const filters: ProjectFilters = {
managerId,
partnerId,
status,
search,
page: pageNum,
limit,
};
const response = await projectsApi.getAll(filters);
setProjects(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching projects'));
} finally {
setIsLoading(false);
}
}, [managerId, partnerId, status, search, limit]);
useEffect(() => {
if (autoFetch) {
fetchProjects(1);
}
}, [fetchProjects, autoFetch]);
const createProject = useCallback(async (data: ProjectCreateInput): Promise<Project> => {
const project = await projectsApi.create(data);
await fetchProjects(page);
return project;
}, [fetchProjects, page]);
const updateProject = useCallback(async (id: string, data: ProjectUpdateInput): Promise<Project> => {
const project = await projectsApi.update(id, data);
await fetchProjects(page);
return project;
}, [fetchProjects, page]);
const deleteProject = useCallback(async (id: string): Promise<void> => {
await projectsApi.delete(id);
await fetchProjects(page);
}, [fetchProjects, page]);
const activateProject = useCallback(async (id: string): Promise<Project> => {
const project = await projectsApi.activate(id);
await fetchProjects(page);
return project;
}, [fetchProjects, page]);
const completeProject = useCallback(async (id: string): Promise<Project> => {
const project = await projectsApi.complete(id);
await fetchProjects(page);
return project;
}, [fetchProjects, page]);
const cancelProject = useCallback(async (id: string): Promise<Project> => {
const project = await projectsApi.cancel(id);
await fetchProjects(page);
return project;
}, [fetchProjects, page]);
const holdProject = useCallback(async (id: string): Promise<Project> => {
const project = await projectsApi.hold(id);
await fetchProjects(page);
return project;
}, [fetchProjects, page]);
return {
projects,
total,
page,
totalPages,
isLoading,
error,
setPage: (p: number) => fetchProjects(p),
refresh: () => fetchProjects(page),
createProject,
updateProject,
deleteProject,
activateProject,
completeProject,
cancelProject,
holdProject,
};
}
// ============================================================================
// useProject Hook (Single Project)
// ============================================================================
export function useProject(projectId: string | null) {
const [project, setProject] = useState<Project | null>(null);
const [stats, setStats] = useState<ProjectStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchProject = useCallback(async () => {
if (!projectId) return;
setIsLoading(true);
setError(null);
try {
const [projectData, statsData] = await Promise.all([
projectsApi.getById(projectId),
projectsApi.getStats(projectId),
]);
setProject(projectData);
setStats(statsData);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching project'));
} finally {
setIsLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchProject();
}, [fetchProject]);
return {
project,
stats,
isLoading,
error,
refresh: fetchProject,
};
}
// ============================================================================
// useTasks Hook
// ============================================================================
export interface UseTasksOptions {
projectId?: string;
assignedTo?: string;
status?: TaskStatus;
priority?: TaskPriority;
parentId?: string;
stageId?: string;
search?: string;
limit?: number;
autoFetch?: boolean;
}
export function useTasks(options: UseTasksOptions = {}) {
const [tasks, setTasks] = useState<Task[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { projectId, assignedTo, status, priority, parentId, stageId, search, limit = 20, autoFetch = true } = options;
const fetchTasks = useCallback(async (pageNum = 1) => {
setIsLoading(true);
setError(null);
try {
const filters: TaskFilters = {
projectId,
assignedTo,
status,
priority,
parentId,
stageId,
search,
page: pageNum,
limit,
};
const response = await tasksApi.getAll(filters);
setTasks(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching tasks'));
} finally {
setIsLoading(false);
}
}, [projectId, assignedTo, status, priority, parentId, stageId, search, limit]);
useEffect(() => {
if (autoFetch) {
fetchTasks(1);
}
}, [fetchTasks, autoFetch]);
const createTask = useCallback(async (data: TaskCreateInput): Promise<Task> => {
const task = await tasksApi.create(data);
await fetchTasks(page);
return task;
}, [fetchTasks, page]);
const updateTask = useCallback(async (id: string, data: TaskUpdateInput): Promise<Task> => {
const task = await tasksApi.update(id, data);
await fetchTasks(page);
return task;
}, [fetchTasks, page]);
const deleteTask = useCallback(async (id: string): Promise<void> => {
await tasksApi.delete(id);
await fetchTasks(page);
}, [fetchTasks, page]);
const startTask = useCallback(async (id: string): Promise<Task> => {
const task = await tasksApi.start(id);
await fetchTasks(page);
return task;
}, [fetchTasks, page]);
const completeTask = useCallback(async (id: string): Promise<Task> => {
const task = await tasksApi.complete(id);
await fetchTasks(page);
return task;
}, [fetchTasks, page]);
const cancelTask = useCallback(async (id: string): Promise<Task> => {
const task = await tasksApi.cancel(id);
await fetchTasks(page);
return task;
}, [fetchTasks, page]);
const moveTask = useCallback(async (taskId: string, stageId: string, sequence?: number): Promise<Task> => {
const task = await tasksApi.moveToStage(taskId, stageId, sequence);
await fetchTasks(page);
return task;
}, [fetchTasks, page]);
return {
tasks,
total,
page,
totalPages,
isLoading,
error,
setPage: (p: number) => fetchTasks(p),
refresh: () => fetchTasks(page),
createTask,
updateTask,
deleteTask,
startTask,
completeTask,
cancelTask,
moveTask,
};
}
// ============================================================================
// useTaskBoard Hook
// ============================================================================
export function useTaskBoard(projectId: string | null) {
const [board, setBoard] = useState<TaskBoard | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchBoard = useCallback(async () => {
if (!projectId) return;
setIsLoading(true);
setError(null);
try {
const data = await tasksApi.getBoard(projectId);
setBoard(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching task board'));
} finally {
setIsLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchBoard();
}, [fetchBoard]);
const moveTask = useCallback(async (taskId: string, stageId: string, sequence?: number): Promise<void> => {
await tasksApi.moveToStage(taskId, stageId, sequence);
await fetchBoard();
}, [fetchBoard]);
return {
board,
isLoading,
error,
refresh: fetchBoard,
moveTask,
};
}
// ============================================================================
// useTaskStages Hook
// ============================================================================
export function useTaskStages(projectId?: string) {
const [stages, setStages] = useState<TaskStage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchStages = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await taskStagesApi.getAll(projectId);
setStages(response.data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching task stages'));
} finally {
setIsLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchStages();
}, [fetchStages]);
const createStage = useCallback(async (data: TaskStageCreateInput): Promise<TaskStage> => {
const stage = await taskStagesApi.create(data);
await fetchStages();
return stage;
}, [fetchStages]);
const updateStage = useCallback(async (id: string, data: TaskStageUpdateInput): Promise<TaskStage> => {
const stage = await taskStagesApi.update(id, data);
await fetchStages();
return stage;
}, [fetchStages]);
const deleteStage = useCallback(async (id: string): Promise<void> => {
await taskStagesApi.delete(id);
await fetchStages();
}, [fetchStages]);
const reorderStages = useCallback(async (stageIds: string[]): Promise<void> => {
await taskStagesApi.reorder(stageIds);
await fetchStages();
}, [fetchStages]);
return {
stages,
isLoading,
error,
refresh: fetchStages,
createStage,
updateStage,
deleteStage,
reorderStages,
};
}
// ============================================================================
// useTimesheets Hook
// ============================================================================
export interface UseTimesheetsOptions {
projectId?: string;
taskId?: string;
employeeId?: string;
dateFrom?: string;
dateTo?: string;
isBillable?: boolean;
isBilled?: boolean;
search?: string;
limit?: number;
autoFetch?: boolean;
}
export function useTimesheets(options: UseTimesheetsOptions = {}) {
const [timesheets, setTimesheets] = useState<Timesheet[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { projectId, taskId, employeeId, dateFrom, dateTo, isBillable, isBilled, search, limit = 20, autoFetch = true } = options;
const fetchTimesheets = useCallback(async (pageNum = 1) => {
setIsLoading(true);
setError(null);
try {
const filters: TimesheetFilters = {
projectId,
taskId,
employeeId,
dateFrom,
dateTo,
isBillable,
isBilled,
search,
page: pageNum,
limit,
};
const response = await timesheetsApi.getAll(filters);
setTimesheets(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching timesheets'));
} finally {
setIsLoading(false);
}
}, [projectId, taskId, employeeId, dateFrom, dateTo, isBillable, isBilled, search, limit]);
useEffect(() => {
if (autoFetch) {
fetchTimesheets(1);
}
}, [fetchTimesheets, autoFetch]);
const createTimesheet = useCallback(async (data: TimesheetCreateInput): Promise<Timesheet> => {
const timesheet = await timesheetsApi.create(data);
await fetchTimesheets(page);
return timesheet;
}, [fetchTimesheets, page]);
const updateTimesheet = useCallback(async (id: string, data: TimesheetUpdateInput): Promise<Timesheet> => {
const timesheet = await timesheetsApi.update(id, data);
await fetchTimesheets(page);
return timesheet;
}, [fetchTimesheets, page]);
const deleteTimesheet = useCallback(async (id: string): Promise<void> => {
await timesheetsApi.delete(id);
await fetchTimesheets(page);
}, [fetchTimesheets, page]);
// Calculate totals
const totalHours = timesheets.reduce((sum, t) => sum + t.hours, 0);
const billableHours = timesheets.filter(t => t.isBillable).reduce((sum, t) => sum + t.hours, 0);
const billedHours = timesheets.filter(t => t.isBilled).reduce((sum, t) => sum + t.hours, 0);
return {
timesheets,
total,
page,
totalPages,
isLoading,
error,
totalHours,
billableHours,
billedHours,
setPage: (p: number) => fetchTimesheets(p),
refresh: () => fetchTimesheets(page),
createTimesheet,
updateTimesheet,
deleteTimesheet,
};
}

View File

@ -0,0 +1,3 @@
export * from './api/projects.api';
export * from './types';
export * from './hooks';

View File

@ -0,0 +1 @@
export * from './projects.types';

View File

@ -0,0 +1,262 @@
// Projects Types - Projects, Tasks, Timesheets
// ============================================================================
// Project Types
// ============================================================================
export type ProjectStatus = 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold';
export type ProjectPrivacy = 'public' | 'private' | 'followers';
export interface Project {
id: string;
tenantId: string;
companyId: string;
name: string;
code?: string;
description?: string;
managerId?: string;
managerName?: string;
partnerId?: string;
partnerName?: string;
status: ProjectStatus;
privacy: ProjectPrivacy;
dateStart?: string;
dateEnd?: string;
plannedHours?: number;
allowTimesheets: boolean;
taskCount?: number;
completedTaskCount?: number;
totalHours?: number;
tags?: string[];
color?: string;
createdAt: string;
updatedAt: string;
}
export interface ProjectCreateInput {
name: string;
code?: string;
description?: string;
managerId?: string;
partnerId?: string;
status?: ProjectStatus;
privacy?: ProjectPrivacy;
dateStart?: string;
dateEnd?: string;
plannedHours?: number;
allowTimesheets?: boolean;
tags?: string[];
color?: string;
}
export interface ProjectUpdateInput extends Partial<ProjectCreateInput> {}
export interface ProjectFilters {
companyId?: string;
managerId?: string;
partnerId?: string;
status?: ProjectStatus;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Task Types
// ============================================================================
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled';
export type TaskPriority = 'low' | 'normal' | 'high' | 'urgent';
export interface Task {
id: string;
tenantId: string;
projectId: string;
projectName?: string;
name: string;
description?: string;
assignedTo?: string;
assignedToName?: string;
parentId?: string;
parentName?: string;
priority: TaskPriority;
status: TaskStatus;
dateDeadline?: string;
dateStart?: string;
dateEnd?: string;
estimatedHours?: number;
spentHours?: number;
remainingHours?: number;
sequence: number;
stageId?: string;
stageName?: string;
tags?: string[];
subtaskCount?: number;
completedSubtaskCount?: number;
createdAt: string;
updatedAt: string;
}
export interface TaskCreateInput {
projectId: string;
name: string;
description?: string;
assignedTo?: string;
parentId?: string;
priority?: TaskPriority;
status?: TaskStatus;
dateDeadline?: string;
dateStart?: string;
estimatedHours?: number;
sequence?: number;
stageId?: string;
tags?: string[];
}
export interface TaskUpdateInput extends Partial<Omit<TaskCreateInput, 'projectId'>> {}
export interface TaskFilters {
projectId?: string;
assignedTo?: string;
status?: TaskStatus;
priority?: TaskPriority;
parentId?: string;
stageId?: string;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Timesheet Types
// ============================================================================
export interface Timesheet {
id: string;
tenantId: string;
companyId: string;
projectId: string;
projectName?: string;
taskId?: string;
taskName?: string;
employeeId: string;
employeeName?: string;
date: string;
hours: number;
description?: string;
isBillable: boolean;
isBilled?: boolean;
invoiceLineId?: string;
createdAt: string;
updatedAt: string;
}
export interface TimesheetCreateInput {
projectId: string;
taskId?: string;
employeeId: string;
date: string;
hours: number;
description?: string;
isBillable?: boolean;
}
export interface TimesheetUpdateInput extends Partial<Omit<TimesheetCreateInput, 'projectId' | 'employeeId'>> {}
export interface TimesheetFilters {
companyId?: string;
projectId?: string;
taskId?: string;
employeeId?: string;
dateFrom?: string;
dateTo?: string;
isBillable?: boolean;
isBilled?: boolean;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Task Stage Types
// ============================================================================
export interface TaskStage {
id: string;
tenantId: string;
projectId?: string;
name: string;
sequence: number;
isFolded?: boolean;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface TaskStageCreateInput {
projectId?: string;
name: string;
sequence?: number;
isFolded?: boolean;
description?: string;
}
export interface TaskStageUpdateInput extends Partial<TaskStageCreateInput> {}
// ============================================================================
// Project Stats Types
// ============================================================================
export interface ProjectStats {
projectId: string;
totalTasks: number;
completedTasks: number;
inProgressTasks: number;
overdueTasks: number;
totalHours: number;
billableHours: number;
billedHours: number;
completionPercentage: number;
}
export interface TaskBoard {
stages: {
id: string;
name: string;
sequence: number;
tasks: Task[];
}[];
}
// ============================================================================
// Response Types
// ============================================================================
export interface ProjectsResponse {
data: Project[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface TasksResponse {
data: Task[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface TimesheetsResponse {
data: Timesheet[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface TaskStagesResponse {
data: TaskStage[];
total: number;
}

View File

@ -0,0 +1,157 @@
import { api } from '@services/api/axios-instance';
import type {
PurchaseOrder,
CreatePurchaseOrderDto,
UpdatePurchaseOrderDto,
PurchaseOrderFilters,
PurchaseOrdersResponse,
PurchaseReceipt,
CreatePurchaseReceiptDto,
PurchaseReceiptFilters,
PurchaseReceiptsResponse,
} from '../types';
const ORDERS_URL = '/api/v1/purchases/orders';
const RECEIPTS_URL = '/api/v1/purchases/receipts';
export const purchasesApi = {
// ==================== Purchase Orders ====================
// Get all purchase orders with filters
getOrders: async (filters?: PurchaseOrderFilters): Promise<PurchaseOrdersResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
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<PurchaseOrdersResponse>(`${ORDERS_URL}?${params.toString()}`);
return response.data;
},
// Get purchase order by ID
getOrderById: async (id: string): Promise<PurchaseOrder> => {
const response = await api.get<PurchaseOrder>(`${ORDERS_URL}/${id}`);
return response.data;
},
// Create purchase order
createOrder: async (data: CreatePurchaseOrderDto): Promise<PurchaseOrder> => {
const response = await api.post<PurchaseOrder>(ORDERS_URL, data);
return response.data;
},
// Update purchase order
updateOrder: async (id: string, data: UpdatePurchaseOrderDto): Promise<PurchaseOrder> => {
const response = await api.patch<PurchaseOrder>(`${ORDERS_URL}/${id}`, data);
return response.data;
},
// Delete purchase order
deleteOrder: async (id: string): Promise<void> => {
await api.delete(`${ORDERS_URL}/${id}`);
},
// ==================== Order Actions ====================
// Confirm purchase order
confirmOrder: async (id: string): Promise<PurchaseOrder> => {
const response = await api.post<PurchaseOrder>(`${ORDERS_URL}/${id}/confirm`);
return response.data;
},
// Cancel purchase order
cancelOrder: async (id: string): Promise<PurchaseOrder> => {
const response = await api.post<PurchaseOrder>(`${ORDERS_URL}/${id}/cancel`);
return response.data;
},
// Get draft orders
getDrafts: async (filters?: Omit<PurchaseOrderFilters, 'status'>): Promise<PurchaseOrdersResponse> => {
return purchasesApi.getOrders({ ...filters, status: 'draft' });
},
// Get confirmed orders
getConfirmed: async (filters?: Omit<PurchaseOrderFilters, 'status'>): Promise<PurchaseOrdersResponse> => {
return purchasesApi.getOrders({ ...filters, status: 'confirmed' });
},
// Get orders by supplier
getBySupplier: async (partnerId: string, filters?: Omit<PurchaseOrderFilters, 'partnerId'>): Promise<PurchaseOrdersResponse> => {
return purchasesApi.getOrders({ ...filters, partnerId });
},
// ==================== Purchase Receipts ====================
// Get all receipts
getReceipts: async (filters?: PurchaseReceiptFilters): Promise<PurchaseReceiptsResponse> => {
const params = new URLSearchParams();
if (filters?.purchaseOrderId) params.append('purchaseOrderId', filters.purchaseOrderId);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
const response = await api.get<PurchaseReceiptsResponse>(`${RECEIPTS_URL}?${params.toString()}`);
return response.data;
},
// Get receipt by ID
getReceiptById: async (id: string): Promise<PurchaseReceipt> => {
const response = await api.get<PurchaseReceipt>(`${RECEIPTS_URL}/${id}`);
return response.data;
},
// Create receipt (receive goods)
createReceipt: async (data: CreatePurchaseReceiptDto): Promise<PurchaseReceipt> => {
const response = await api.post<PurchaseReceipt>(RECEIPTS_URL, data);
return response.data;
},
// Confirm receipt
confirmReceipt: async (id: string): Promise<PurchaseReceipt> => {
const response = await api.post<PurchaseReceipt>(`${RECEIPTS_URL}/${id}/confirm`);
return response.data;
},
// Cancel receipt
cancelReceipt: async (id: string): Promise<PurchaseReceipt> => {
const response = await api.post<PurchaseReceipt>(`${RECEIPTS_URL}/${id}/cancel`);
return response.data;
},
// Get receipts by order
getReceiptsByOrder: async (purchaseOrderId: string): Promise<PurchaseReceiptsResponse> => {
return purchasesApi.getReceipts({ purchaseOrderId });
},
// Generate purchase order PDF
generateOrderPdf: async (id: string): Promise<Blob> => {
const response = await api.get(`${ORDERS_URL}/${id}/pdf`, {
responseType: 'blob',
});
return response.data;
},
// Download purchase order PDF
downloadOrderPdf: async (id: string, fileName?: string): Promise<void> => {
const blob = await purchasesApi.generateOrderPdf(id);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName || `purchase-order-${id}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
};

View File

@ -0,0 +1,11 @@
export {
usePurchaseOrders,
usePurchaseOrder,
usePurchaseReceipts,
usePurchaseReceipt,
} from './usePurchases';
export type {
UsePurchaseOrdersOptions,
UsePurchaseReceiptsOptions,
} from './usePurchases';

Some files were not shown because too many files have changed in this diff Show More