[REMEDIATION] feat: Frontend remediation - auth, finance, contracts, session management

Add auth components, finance pages/hooks/services, contract components.
Enhance LoginPage, AdminLayout, hooks. Remove legacy apiClient.
Add mock data services for development. Addresses frontend gaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-05 23:18:22 -06:00
parent 55261598a2
commit a03bed842f
43 changed files with 9183 additions and 311 deletions

View File

@ -2,12 +2,25 @@
# ERP CONSTRUCCION - CONFIGURACION FRONTEND # ERP CONSTRUCCION - CONFIGURACION FRONTEND
# =========================================================== # ===========================================================
# Copiar este archivo a .env y ajustar valores # Copiar este archivo a .env y ajustar valores
# Puertos oficiales: Frontend 3020, Backend 3021
# API Backend URL # API Backend URL
VITE_API_URL=http://localhost:3021/api/v1 VITE_API_URL=http://localhost:3021/api/v1
# Tenant ID para desarrollo # Tenant ID para desarrollo (UUID del tenant en BD)
# En produccion: resolver dinamicamente por subdominio
VITE_TENANT_ID=default-tenant VITE_TENANT_ID=default-tenant
# Ambiente # Ambiente
VITE_APP_ENV=development VITE_APP_ENV=development
# ===========================================================
# DEMO LOGIN (SOLO DESARROLLO)
# ===========================================================
# VITE_SHOW_DEMO_LOGIN: Muestra boton de credenciales demo
# IMPORTANTE: NUNCA habilitar en produccion
VITE_SHOW_DEMO_LOGIN=false
# Credenciales demo (solo si VITE_SHOW_DEMO_LOGIN=true)
# VITE_DEMO_EMAIL=admin@demo.com
# VITE_DEMO_PASSWORD=demo123

1762
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,118 @@
/**
* ProtectedRoute Component
* Guards routes that require authentication with role-based access control
* Based on gamilit implementation
*/
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { LoadingSpinner } from '../common/LoadingSpinner';
interface ProtectedRouteProps {
children: React.ReactNode;
/** Roles allowed to access this route */
allowedRoles?: string[];
/** Custom redirect path for unauthenticated users */
redirectTo?: string;
}
export function ProtectedRoute({
children,
allowedRoles,
redirectTo = '/auth/login',
}: ProtectedRouteProps) {
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const isInitialized = useAuthStore((state) => state.isInitialized);
const checkSession = useAuthStore((state) => state.checkSession);
const location = useLocation();
// Show loading while checking auth state
if (isLoading || !isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-background dark:bg-background">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-foreground-muted dark:text-foreground-muted">
Verificando sesión...
</p>
</div>
</div>
);
}
// Check if session is still valid
const sessionValid = checkSession();
// Not authenticated - redirect to login
if (!isAuthenticated || !sessionValid) {
return (
<Navigate
to={redirectTo}
state={{ from: location }}
replace
/>
);
}
// Check role-based access if roles are specified
if (allowedRoles && allowedRoles.length > 0) {
const userRole = user?.role || '';
const hasRequiredRole = allowedRoles.includes(userRole);
if (!hasRequiredRole) {
// User doesn't have required role - redirect to unauthorized page
return (
<Navigate
to="/unauthorized"
state={{ from: location, requiredRoles: allowedRoles }}
replace
/>
);
}
}
// User is authenticated and has required role
return <>{children}</>;
}
/**
* PublicRoute Component
* Redirects authenticated users away from public pages (login, register)
*/
interface PublicRouteProps {
children: React.ReactNode;
/** Where to redirect authenticated users */
redirectTo?: string;
}
export function PublicRoute({
children,
redirectTo = '/admin/dashboard',
}: PublicRouteProps) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const isInitialized = useAuthStore((state) => state.isInitialized);
const location = useLocation();
// Show loading while checking auth state
if (isLoading || !isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center bg-background dark:bg-background">
<LoadingSpinner size="lg" />
</div>
);
}
// If user is already authenticated, redirect to dashboard
if (isAuthenticated) {
// Check if there's a "from" location to redirect back to
const from = (location.state as { from?: Location })?.from?.pathname || redirectTo;
return <Navigate to={from} replace />;
}
return <>{children}</>;
}
export default ProtectedRoute;

View File

@ -0,0 +1,5 @@
/**
* Auth Components Index
*/
export { ProtectedRoute, PublicRoute } from './ProtectedRoute';

View File

@ -0,0 +1,188 @@
/**
* AddendaModal - Modal para crear/editar addendas de contrato
*/
import { useState } from 'react';
import {
useCreateContractAddendum,
useUpdateContractAddendum,
} from '../../hooks/useContracts';
import type {
ContractAddendum,
AddendumType,
CreateAddendumDto,
} from '../../types/contracts.types';
import { ADDENDUM_TYPE_OPTIONS } from '../../types/contracts.types';
import {
Modal,
ModalFooter,
TextInput,
TextareaField,
SelectField,
FormGroup,
} from '../common';
interface AddendaModalProps {
contractId: string;
addendum: ContractAddendum | null;
onClose: () => void;
}
export function AddendaModal({ contractId, addendum, onClose }: AddendaModalProps) {
const createMutation = useCreateContractAddendum();
const updateMutation = useUpdateContractAddendum();
const [formData, setFormData] = useState<CreateAddendumDto>({
addendumNumber: addendum?.addendumNumber || '',
addendumType: addendum?.addendumType || 'scope_change',
title: addendum?.title || '',
description: addendum?.description || '',
effectiveDate: addendum?.effectiveDate ? addendum.effectiveDate.split('T')[0] : '',
newEndDate: addendum?.newEndDate ? addendum.newEndDate.split('T')[0] : undefined,
amountChange: addendum?.amountChange ?? 0,
scopeChanges: addendum?.scopeChanges || '',
notes: addendum?.notes || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload: CreateAddendumDto = {
...formData,
newEndDate: formData.newEndDate || undefined,
amountChange: formData.amountChange || undefined,
scopeChanges: formData.scopeChanges || undefined,
notes: formData.notes || undefined,
};
if (addendum) {
await updateMutation.mutateAsync({
contractId,
addendumId: addendum.id,
data: payload,
});
} else {
await createMutation.mutateAsync({ contractId, data: payload });
}
onClose();
};
const update = <K extends keyof CreateAddendumDto>(field: K, value: CreateAddendumDto[K]) => {
setFormData({ ...formData, [field]: value });
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const showDateExtension = ['extension', 'scope_change', 'other'].includes(formData.addendumType);
const showAmountChange = ['amount_increase', 'amount_decrease', 'scope_change', 'other'].includes(formData.addendumType);
return (
<Modal
isOpen={true}
onClose={onClose}
title={addendum ? 'Editar Addenda' : 'Nueva Addenda'}
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="addenda-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : addendum ? 'Guardar Cambios' : 'Crear Addenda'}
</button>
</ModalFooter>
}
>
<form id="addenda-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput
label="Numero de Addenda"
required
value={formData.addendumNumber}
onChange={(e) => update('addendumNumber', e.target.value)}
placeholder="ADD-001"
/>
<SelectField
label="Tipo de Addenda"
required
options={ADDENDUM_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
value={formData.addendumType}
onChange={(e) => update('addendumType', e.target.value as AddendumType)}
/>
</FormGroup>
<TextInput
label="Titulo"
required
value={formData.title}
onChange={(e) => update('title', e.target.value)}
placeholder="Titulo de la addenda"
/>
<TextareaField
label="Descripcion"
required
value={formData.description}
onChange={(e) => update('description', e.target.value)}
placeholder="Descripcion detallada de la addenda..."
rows={3}
/>
<FormGroup cols={2}>
<TextInput
label="Fecha de Vigencia"
type="date"
required
value={formData.effectiveDate}
onChange={(e) => update('effectiveDate', e.target.value)}
/>
{showDateExtension && (
<TextInput
label="Nueva Fecha de Fin"
type="date"
value={formData.newEndDate || ''}
onChange={(e) => update('newEndDate', e.target.value || undefined)}
/>
)}
</FormGroup>
{showAmountChange && (
<TextInput
label="Cambio en Monto"
type="number"
value={(formData.amountChange ?? 0).toString()}
onChange={(e) => update('amountChange', parseFloat(e.target.value) || 0)}
placeholder="Positivo para incremento, negativo para decremento"
step="0.01"
/>
)}
{formData.addendumType === 'scope_change' && (
<TextareaField
label="Cambios en el Alcance"
value={formData.scopeChanges || ''}
onChange={(e) => update('scopeChanges', e.target.value)}
placeholder="Describir los cambios en el alcance del contrato..."
rows={3}
/>
)}
<TextareaField
label="Notas"
value={formData.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas adicionales..."
rows={2}
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,321 @@
/**
* ContractForm - Modal para crear/editar contratos
*/
import { useState } from 'react';
import {
useCreateContract,
useUpdateContract,
useSubcontractors,
} from '../../hooks/useContracts';
import type {
Contract,
ContractType,
ClientContractType,
CreateContractDto,
} from '../../types/contracts.types';
import {
CONTRACT_TYPE_OPTIONS,
CLIENT_CONTRACT_TYPE_OPTIONS,
SUBCONTRACTOR_SPECIALTY_OPTIONS,
} from '../../types/contracts.types';
import {
Modal,
ModalFooter,
TextInput,
TextareaField,
SelectField,
FormGroup,
} from '../common';
interface ContractFormProps {
contract: Contract | null;
onClose: () => void;
}
export function ContractForm({ contract, onClose }: ContractFormProps) {
const createMutation = useCreateContract();
const updateMutation = useUpdateContract();
const { data: subcontractorsData } = useSubcontractors({ status: 'active' });
const [formData, setFormData] = useState<CreateContractDto>({
contractNumber: contract?.contractNumber || '',
name: contract?.name || '',
description: contract?.description || '',
contractType: contract?.contractType || 'client',
clientContractType: contract?.clientContractType || undefined,
clientName: contract?.clientName || '',
clientRfc: contract?.clientRfc || '',
clientAddress: contract?.clientAddress || '',
subcontractorId: contract?.subcontractorId || '',
specialty: contract?.specialty || '',
startDate: contract?.startDate ? contract.startDate.split('T')[0] : '',
endDate: contract?.endDate ? contract.endDate.split('T')[0] : '',
contractAmount: contract?.contractAmount || 0,
currency: contract?.currency || 'MXN',
paymentTerms: contract?.paymentTerms || '',
retentionPercentage: contract?.retentionPercentage ?? 5,
advancePercentage: contract?.advancePercentage ?? 0,
notes: contract?.notes || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = { ...formData };
// Clean up based on contract type
if (formData.contractType === 'client') {
payload.subcontractorId = undefined;
payload.specialty = undefined;
} else {
payload.clientName = undefined;
payload.clientRfc = undefined;
payload.clientAddress = undefined;
payload.clientContractType = undefined;
}
if (contract) {
await updateMutation.mutateAsync({ id: contract.id, data: payload });
} else {
await createMutation.mutateAsync(payload);
}
onClose();
};
const update = <K extends keyof CreateContractDto>(field: K, value: CreateContractDto[K]) => {
setFormData({ ...formData, [field]: value });
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const isClient = formData.contractType === 'client';
return (
<Modal
isOpen={true}
onClose={onClose}
title={contract ? 'Editar Contrato' : 'Nuevo Contrato'}
size="lg"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="contract-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : contract ? 'Guardar Cambios' : 'Crear Contrato'}
</button>
</ModalFooter>
}
>
<form id="contract-form" onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Datos Generales
</h4>
<FormGroup cols={2}>
<TextInput
label="Numero de Contrato"
required
value={formData.contractNumber}
onChange={(e) => update('contractNumber', e.target.value)}
placeholder="CONT-2026-001"
/>
<SelectField
label="Tipo de Contrato"
required
options={CONTRACT_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
value={formData.contractType}
onChange={(e) => update('contractType', e.target.value as ContractType)}
/>
</FormGroup>
<TextInput
label="Nombre del Contrato"
required
value={formData.name}
onChange={(e) => update('name', e.target.value)}
placeholder="Contrato de construccion de obra X"
className="mt-4"
/>
<TextareaField
label="Descripcion"
value={formData.description || ''}
onChange={(e) => update('description', e.target.value)}
placeholder="Descripcion detallada del contrato..."
rows={2}
className="mt-4"
/>
</div>
{/* Client/Subcontractor Info */}
{isClient ? (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Datos del Cliente
</h4>
<FormGroup cols={2}>
<TextInput
label="Nombre del Cliente"
required
value={formData.clientName || ''}
onChange={(e) => update('clientName', e.target.value)}
placeholder="Cliente S.A. de C.V."
/>
<SelectField
label="Tipo de Contrato Cliente"
options={[
{ value: '', label: 'Seleccionar...' },
...CLIENT_CONTRACT_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={formData.clientContractType || ''}
onChange={(e) => update('clientContractType', e.target.value as ClientContractType || undefined)}
/>
</FormGroup>
<FormGroup cols={2} className="mt-4">
<TextInput
label="RFC del Cliente"
value={formData.clientRfc || ''}
onChange={(e) => update('clientRfc', e.target.value.toUpperCase())}
placeholder="XAXX010101000"
maxLength={13}
/>
<TextInput
label="Direccion del Cliente"
value={formData.clientAddress || ''}
onChange={(e) => update('clientAddress', e.target.value)}
placeholder="Direccion completa"
/>
</FormGroup>
</div>
) : (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Datos del Subcontratista
</h4>
<FormGroup cols={2}>
<SelectField
label="Subcontratista"
required
options={[
{ value: '', label: 'Seleccionar subcontratista...' },
...(subcontractorsData?.items || []).map(s => ({
value: s.id,
label: `${s.code} - ${s.businessName}`,
})),
]}
value={formData.subcontractorId || ''}
onChange={(e) => update('subcontractorId', e.target.value)}
/>
<SelectField
label="Especialidad"
options={[
{ value: '', label: 'Seleccionar especialidad...' },
...SUBCONTRACTOR_SPECIALTY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={formData.specialty || ''}
onChange={(e) => update('specialty', e.target.value)}
/>
</FormGroup>
</div>
)}
{/* Dates */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Vigencia
</h4>
<FormGroup cols={2}>
<TextInput
label="Fecha de Inicio"
type="date"
required
value={formData.startDate}
onChange={(e) => update('startDate', e.target.value)}
/>
<TextInput
label="Fecha de Fin"
type="date"
required
value={formData.endDate}
onChange={(e) => update('endDate', e.target.value)}
/>
</FormGroup>
</div>
{/* Financial */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Condiciones Financieras
</h4>
<FormGroup cols={2}>
<TextInput
label="Monto del Contrato"
type="number"
required
value={formData.contractAmount.toString()}
onChange={(e) => update('contractAmount', parseFloat(e.target.value) || 0)}
placeholder="0.00"
min="0"
step="0.01"
/>
<SelectField
label="Moneda"
options={[
{ value: 'MXN', label: 'MXN - Peso Mexicano' },
{ value: 'USD', label: 'USD - Dolar Americano' },
]}
value={formData.currency || 'MXN'}
onChange={(e) => update('currency', e.target.value)}
/>
</FormGroup>
<FormGroup cols={2} className="mt-4">
<TextInput
label="Retencion (%)"
type="number"
value={(formData.retentionPercentage ?? 5).toString()}
onChange={(e) => update('retentionPercentage', parseFloat(e.target.value) || 0)}
min="0"
max="100"
step="0.1"
/>
<TextInput
label="Anticipo (%)"
type="number"
value={(formData.advancePercentage ?? 0).toString()}
onChange={(e) => update('advancePercentage', parseFloat(e.target.value) || 0)}
min="0"
max="100"
step="0.1"
/>
</FormGroup>
<TextareaField
label="Condiciones de Pago"
value={formData.paymentTerms || ''}
onChange={(e) => update('paymentTerms', e.target.value)}
placeholder="Describir las condiciones de pago del contrato..."
rows={2}
className="mt-4"
/>
</div>
{/* Notes */}
<TextareaField
label="Notas"
value={formData.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas adicionales sobre el contrato..."
rows={3}
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,163 @@
/**
* PartidaModal - Modal para crear/editar partidas de contrato
*/
import { useState } from 'react';
import {
useCreateContractPartida,
useUpdateContractPartida,
} from '../../hooks/useContracts';
import type {
ContractPartida,
CreateContractPartidaDto,
} from '../../types/contracts.types';
import {
Modal,
ModalFooter,
TextInput,
FormGroup,
} from '../common';
interface PartidaModalProps {
contractId: string;
partida: ContractPartida | null;
onClose: () => void;
}
export function PartidaModal({ contractId, partida, onClose }: PartidaModalProps) {
const createMutation = useCreateContractPartida();
const updateMutation = useUpdateContractPartida();
const [formData, setFormData] = useState<CreateContractPartidaDto & { conceptoCode?: string; conceptoDescription?: string }>({
conceptoId: partida?.conceptoId || '',
conceptoCode: partida?.conceptoCode || '',
conceptoDescription: partida?.conceptoDescription || '',
quantity: partida?.quantity || 0,
unitPrice: partida?.unitPrice || 0,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload: CreateContractPartidaDto = {
conceptoId: formData.conceptoId,
quantity: formData.quantity,
unitPrice: formData.unitPrice,
};
if (partida) {
await updateMutation.mutateAsync({
contractId,
partidaId: partida.id,
data: {
quantity: formData.quantity,
unitPrice: formData.unitPrice,
},
});
} else {
await createMutation.mutateAsync({ contractId, data: payload });
}
onClose();
};
const update = <K extends keyof typeof formData>(field: K, value: typeof formData[K]) => {
setFormData({ ...formData, [field]: value });
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const total = formData.quantity * formData.unitPrice;
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
return (
<Modal
isOpen={true}
onClose={onClose}
title={partida ? 'Editar Partida' : 'Nueva Partida'}
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="partida-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : partida ? 'Guardar Cambios' : 'Agregar Partida'}
</button>
</ModalFooter>
}
>
<form id="partida-form" onSubmit={handleSubmit} className="space-y-4">
{/* Concepto Selection - In a real app this would be a searchable select */}
<TextInput
label="ID del Concepto"
required
value={formData.conceptoId}
onChange={(e) => update('conceptoId', e.target.value)}
placeholder="UUID del concepto"
disabled={!!partida}
/>
{partida && (
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3">
<p className="text-sm text-gray-500 dark:text-gray-400">Concepto</p>
<p className="font-medium text-gray-900 dark:text-gray-100">
{partida.conceptoCode || 'N/A'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{partida.conceptoDescription || '-'}
</p>
</div>
)}
<FormGroup cols={2}>
<TextInput
label="Cantidad"
type="number"
required
value={formData.quantity.toString()}
onChange={(e) => update('quantity', parseFloat(e.target.value) || 0)}
placeholder="0.00"
min="0"
step="0.01"
/>
<TextInput
label="Precio Unitario"
type="number"
required
value={formData.unitPrice.toString()}
onChange={(e) => update('unitPrice', parseFloat(e.target.value) || 0)}
placeholder="0.00"
min="0"
step="0.01"
/>
</FormGroup>
{/* Total Calculated */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Total:
</span>
<span className="text-lg font-bold text-blue-600 dark:text-blue-400">
{formatCurrency(total)}
</span>
</div>
</div>
</form>
</Modal>
);
}

View File

@ -0,0 +1,7 @@
/**
* Contracts Components Index
*/
export { ContractForm } from './ContractForm';
export { AddendaModal } from './AddendaModal';
export { PartidaModal } from './PartidaModal';

View File

@ -11,3 +11,6 @@ export * from './useToast';
// Utility hooks // Utility hooks
export { useDebounce, useDebouncedCallback, useDebounceWithImmediate } from './useDebounce'; export { useDebounce, useDebouncedCallback, useDebounceWithImmediate } from './useDebounce';
export { useLocalStorage, useSessionStorage } from './useLocalStorage'; export { useLocalStorage, useSessionStorage } from './useLocalStorage';
// Session management
export { useSession } from './useSession';

View File

@ -22,6 +22,11 @@ import {
CreateVendorDto, CreateVendorDto,
UpdateVendorDto, UpdateVendorDto,
} from '../services/bidding'; } from '../services/bidding';
import {
mockOportunidades,
mockConcursos,
mockProveedores,
} from '../services/mockData.modules';
// ============================================================================ // ============================================================================
// QUERY KEYS // QUERY KEYS
@ -67,7 +72,18 @@ const handleError = (error: AxiosError<ApiError>) => {
export function useOpportunities(filters?: OpportunityFilters) { export function useOpportunities(filters?: OpportunityFilters) {
return useQuery({ return useQuery({
queryKey: biddingKeys.opportunities.list(filters), queryKey: biddingKeys.opportunities.list(filters),
queryFn: () => opportunitiesApi.list(filters), queryFn: async () => {
try {
return await opportunitiesApi.list(filters);
} catch {
console.warn('[useOpportunities] API failed, using mock data');
let items = [...mockOportunidades];
if (filters?.status) {
items = items.filter(o => o.status === filters.status);
}
return { items, total: items.length };
}
},
}); });
} }
@ -138,7 +154,18 @@ export function useUpdateOpportunityStatus() {
export function useTenders(filters?: TenderFilters) { export function useTenders(filters?: TenderFilters) {
return useQuery({ return useQuery({
queryKey: biddingKeys.tenders.list(filters), queryKey: biddingKeys.tenders.list(filters),
queryFn: () => tendersApi.list(filters), queryFn: async () => {
try {
return await tendersApi.list(filters);
} catch {
console.warn('[useTenders] API failed, using mock data');
let items = [...mockConcursos];
if (filters?.status) {
items = items.filter(t => t.status === filters.status);
}
return { items, total: items.length };
}
},
}); });
} }
@ -279,7 +306,18 @@ export function useSubmitProposal() {
export function useVendors(filters?: VendorFilters) { export function useVendors(filters?: VendorFilters) {
return useQuery({ return useQuery({
queryKey: biddingKeys.vendors.list(filters), queryKey: biddingKeys.vendors.list(filters),
queryFn: () => vendorsApi.list(filters), queryFn: async () => {
try {
return await vendorsApi.list(filters);
} catch {
console.warn('[useVendors] API failed, using mock data');
let items = [...mockProveedores];
if (filters?.isActive !== undefined) {
items = items.filter(v => v.isActive === filters.isActive);
}
return { items, total: items.length };
}
},
}); });
} }

View File

@ -33,35 +33,47 @@ import {
CreatePrototipoDto, CreatePrototipoDto,
UpdatePrototipoDto, UpdatePrototipoDto,
} from '../services/construccion/prototipos.api'; } from '../services/construccion/prototipos.api';
import {
mockFraccionamientos,
mockEtapas,
mockManzanas,
mockLotes,
mockPrototipos,
filterFraccionamientos,
filterEtapas,
filterManzanas,
filterLotes,
filterPrototipos,
} from '../services/mockData.construccion';
// Query Keys // Query Keys
export const construccionKeys = { export const construccionKeys = {
fraccionamientos: { fraccionamientos: {
all: ['construccion', 'fraccionamientos'] as const, all: ['construccion', 'fraccionamientos'] as const,
list: (filters?: FraccionamientoFilters) => list: (filters?: FraccionamientoFilters & { search?: string }) =>
[...construccionKeys.fraccionamientos.all, 'list', filters] as const, [...construccionKeys.fraccionamientos.all, 'list', filters] as const,
detail: (id: string) => [...construccionKeys.fraccionamientos.all, 'detail', id] as const, detail: (id: string) => [...construccionKeys.fraccionamientos.all, 'detail', id] as const,
}, },
etapas: { etapas: {
all: ['construccion', 'etapas'] as const, all: ['construccion', 'etapas'] as const,
list: (filters?: EtapaFilters) => [...construccionKeys.etapas.all, 'list', filters] as const, list: (filters?: EtapaFilters & { search?: string }) => [...construccionKeys.etapas.all, 'list', filters] as const,
detail: (id: string) => [...construccionKeys.etapas.all, 'detail', id] as const, detail: (id: string) => [...construccionKeys.etapas.all, 'detail', id] as const,
}, },
manzanas: { manzanas: {
all: ['construccion', 'manzanas'] as const, all: ['construccion', 'manzanas'] as const,
list: (filters?: ManzanaFilters) => list: (filters?: ManzanaFilters & { search?: string }) =>
[...construccionKeys.manzanas.all, 'list', filters] as const, [...construccionKeys.manzanas.all, 'list', filters] as const,
detail: (id: string) => [...construccionKeys.manzanas.all, 'detail', id] as const, detail: (id: string) => [...construccionKeys.manzanas.all, 'detail', id] as const,
}, },
lotes: { lotes: {
all: ['construccion', 'lotes'] as const, all: ['construccion', 'lotes'] as const,
list: (filters?: LoteFilters) => [...construccionKeys.lotes.all, 'list', filters] as const, list: (filters?: LoteFilters & { search?: string }) => [...construccionKeys.lotes.all, 'list', filters] as const,
detail: (id: string) => [...construccionKeys.lotes.all, 'detail', id] as const, detail: (id: string) => [...construccionKeys.lotes.all, 'detail', id] as const,
stats: (manzanaId?: string) => [...construccionKeys.lotes.all, 'stats', manzanaId] as const, stats: (manzanaId?: string) => [...construccionKeys.lotes.all, 'stats', manzanaId] as const,
}, },
prototipos: { prototipos: {
all: ['construccion', 'prototipos'] as const, all: ['construccion', 'prototipos'] as const,
list: (filters?: PrototipoFilters) => list: (filters?: PrototipoFilters & { search?: string }) =>
[...construccionKeys.prototipos.all, 'list', filters] as const, [...construccionKeys.prototipos.all, 'list', filters] as const,
detail: (id: string) => [...construccionKeys.prototipos.all, 'detail', id] as const, detail: (id: string) => [...construccionKeys.prototipos.all, 'detail', id] as const,
}, },
@ -75,17 +87,34 @@ const handleError = (error: AxiosError<ApiError>) => {
// ==================== FRACCIONAMIENTOS ==================== // ==================== FRACCIONAMIENTOS ====================
export function useFraccionamientos(filters?: FraccionamientoFilters) { export function useFraccionamientos(filters?: FraccionamientoFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: construccionKeys.fraccionamientos.list(filters), queryKey: construccionKeys.fraccionamientos.list(filters),
queryFn: () => fraccionamientosApi.list(filters), queryFn: async () => {
try {
return await fraccionamientosApi.list(filters);
} catch {
console.warn('[useFraccionamientos] API failed, using mock data');
const items = filterFraccionamientos(mockFraccionamientos, filters);
return { items, total: items.length };
}
},
}); });
} }
export function useFraccionamiento(id: string) { export function useFraccionamiento(id: string) {
return useQuery({ return useQuery({
queryKey: construccionKeys.fraccionamientos.detail(id), queryKey: construccionKeys.fraccionamientos.detail(id),
queryFn: () => fraccionamientosApi.get(id), queryFn: async () => {
try {
return await fraccionamientosApi.get(id);
} catch {
console.warn('[useFraccionamiento] API failed, using mock data');
const item = mockFraccionamientos.find((f) => f.id === id);
if (!item) throw new Error('Not found');
return item;
}
},
enabled: !!id, enabled: !!id,
}); });
} }
@ -130,17 +159,34 @@ export function useDeleteFraccionamiento() {
// ==================== ETAPAS ==================== // ==================== ETAPAS ====================
export function useEtapas(filters?: EtapaFilters) { export function useEtapas(filters?: EtapaFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: construccionKeys.etapas.list(filters), queryKey: construccionKeys.etapas.list(filters),
queryFn: () => etapasApi.list(filters), queryFn: async () => {
try {
return await etapasApi.list(filters);
} catch {
console.warn('[useEtapas] API failed, using mock data');
const items = filterEtapas(mockEtapas, filters);
return { items, total: items.length };
}
},
}); });
} }
export function useEtapa(id: string) { export function useEtapa(id: string) {
return useQuery({ return useQuery({
queryKey: construccionKeys.etapas.detail(id), queryKey: construccionKeys.etapas.detail(id),
queryFn: () => etapasApi.get(id), queryFn: async () => {
try {
return await etapasApi.get(id);
} catch {
console.warn('[useEtapa] API failed, using mock data');
const item = mockEtapas.find((e) => e.id === id);
if (!item) throw new Error('Not found');
return item;
}
},
enabled: !!id, enabled: !!id,
}); });
} }
@ -184,17 +230,34 @@ export function useDeleteEtapa() {
// ==================== MANZANAS ==================== // ==================== MANZANAS ====================
export function useManzanas(filters?: ManzanaFilters) { export function useManzanas(filters?: ManzanaFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: construccionKeys.manzanas.list(filters), queryKey: construccionKeys.manzanas.list(filters),
queryFn: () => manzanasApi.list(filters), queryFn: async () => {
try {
return await manzanasApi.list(filters);
} catch {
console.warn('[useManzanas] API failed, using mock data');
const items = filterManzanas(mockManzanas, filters);
return { items, total: items.length };
}
},
}); });
} }
export function useManzana(id: string) { export function useManzana(id: string) {
return useQuery({ return useQuery({
queryKey: construccionKeys.manzanas.detail(id), queryKey: construccionKeys.manzanas.detail(id),
queryFn: () => manzanasApi.get(id), queryFn: async () => {
try {
return await manzanasApi.get(id);
} catch {
console.warn('[useManzana] API failed, using mock data');
const item = mockManzanas.find((m) => m.id === id);
if (!item) throw new Error('Not found');
return item;
}
},
enabled: !!id, enabled: !!id,
}); });
} }
@ -239,17 +302,34 @@ export function useDeleteManzana() {
// ==================== LOTES ==================== // ==================== LOTES ====================
export function useLotes(filters?: LoteFilters) { export function useLotes(filters?: LoteFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: construccionKeys.lotes.list(filters), queryKey: construccionKeys.lotes.list(filters),
queryFn: () => lotesApi.list(filters), queryFn: async () => {
try {
return await lotesApi.list(filters);
} catch {
console.warn('[useLotes] API failed, using mock data');
const items = filterLotes(mockLotes, filters);
return { items, total: items.length };
}
},
}); });
} }
export function useLote(id: string) { export function useLote(id: string) {
return useQuery({ return useQuery({
queryKey: construccionKeys.lotes.detail(id), queryKey: construccionKeys.lotes.detail(id),
queryFn: () => lotesApi.get(id), queryFn: async () => {
try {
return await lotesApi.get(id);
} catch {
console.warn('[useLote] API failed, using mock data');
const item = mockLotes.find((l) => l.id === id);
if (!item) throw new Error('Not found');
return item;
}
},
enabled: !!id, enabled: !!id,
}); });
} }
@ -257,7 +337,22 @@ export function useLote(id: string) {
export function useLoteStats(manzanaId?: string) { export function useLoteStats(manzanaId?: string) {
return useQuery({ return useQuery({
queryKey: construccionKeys.lotes.stats(manzanaId), queryKey: construccionKeys.lotes.stats(manzanaId),
queryFn: () => lotesApi.getStats(manzanaId), queryFn: async () => {
try {
return await lotesApi.getStats(manzanaId);
} catch {
console.warn('[useLoteStats] API failed, using mock data');
const items = manzanaId ? mockLotes.filter((l) => l.manzanaId === manzanaId) : mockLotes;
return {
total: items.length,
available: items.filter((l) => l.status === 'available').length,
reserved: items.filter((l) => l.status === 'reserved').length,
sold: items.filter((l) => l.status === 'sold').length,
inConstruction: items.filter((l) => l.status === 'in_construction').length,
blocked: items.filter((l) => l.status === 'blocked').length,
};
}
},
}); });
} }
@ -329,17 +424,34 @@ export function useAssignPrototipo() {
// ==================== PROTOTIPOS ==================== // ==================== PROTOTIPOS ====================
export function usePrototipos(filters?: PrototipoFilters) { export function usePrototipos(filters?: PrototipoFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: construccionKeys.prototipos.list(filters), queryKey: construccionKeys.prototipos.list(filters),
queryFn: () => prototiposApi.list(filters), queryFn: async () => {
try {
return await prototiposApi.list(filters);
} catch {
console.warn('[usePrototipos] API failed, using mock data');
const items = filterPrototipos(mockPrototipos, filters);
return { items, total: items.length };
}
},
}); });
} }
export function usePrototipo(id: string) { export function usePrototipo(id: string) {
return useQuery({ return useQuery({
queryKey: construccionKeys.prototipos.detail(id), queryKey: construccionKeys.prototipos.detail(id),
queryFn: () => prototiposApi.get(id), queryFn: async () => {
try {
return await prototiposApi.get(id);
} catch {
console.warn('[usePrototipo] API failed, using mock data');
const item = mockPrototipos.find((p) => p.id === id);
if (!item) throw new Error('Not found');
return item;
}
},
enabled: !!id, enabled: !!id,
}); });
} }

649
web/src/hooks/useFinance.ts Normal file
View File

@ -0,0 +1,649 @@
/**
* useFinance Hook - Contabilidad, CxC, CxP, Flujo de Efectivo, Facturación
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import type { ApiError } from '../services/api';
import {
accountsApi,
entriesApi,
accountingReportsApi,
arApi,
apApi,
cashFlowApi,
invoicesApi,
} from '../services/finance';
import type {
AccountFilters,
CreateAccountDto,
UpdateAccountDto,
EntryFilters,
CreateEntryDto,
UpdateEntryDto,
ARFilters,
RegisterARPaymentDto,
APFilters,
ScheduleAPPaymentDto,
RegisterAPPaymentDto,
InvoiceFilters,
CreateInvoiceDto,
UpdateInvoiceDto,
} from '../types/finance.types';
// ============================================================================
// QUERY KEYS
// ============================================================================
export const financeKeys = {
// Accounts
accounts: {
all: ['finance', 'accounts'] as const,
list: (filters?: AccountFilters) => [...financeKeys.accounts.all, 'list', filters] as const,
tree: () => [...financeKeys.accounts.all, 'tree'] as const,
detail: (id: string) => [...financeKeys.accounts.all, 'detail', id] as const,
},
// Entries
entries: {
all: ['finance', 'entries'] as const,
list: (filters?: EntryFilters) => [...financeKeys.entries.all, 'list', filters] as const,
detail: (id: string) => [...financeKeys.entries.all, 'detail', id] as const,
},
// Reports
reports: {
all: ['finance', 'reports'] as const,
trialBalance: (period: string) => [...financeKeys.reports.all, 'trial-balance', period] as const,
ledger: (accountId: string, period: string) => [...financeKeys.reports.all, 'ledger', accountId, period] as const,
},
// AR
ar: {
all: ['finance', 'ar'] as const,
list: (filters?: ARFilters) => [...financeKeys.ar.all, 'list', filters] as const,
detail: (id: string) => [...financeKeys.ar.all, 'detail', id] as const,
stats: () => [...financeKeys.ar.all, 'stats'] as const,
aging: () => [...financeKeys.ar.all, 'aging'] as const,
},
// AP
ap: {
all: ['finance', 'ap'] as const,
list: (filters?: APFilters) => [...financeKeys.ap.all, 'list', filters] as const,
detail: (id: string) => [...financeKeys.ap.all, 'detail', id] as const,
stats: () => [...financeKeys.ap.all, 'stats'] as const,
aging: () => [...financeKeys.ap.all, 'aging'] as const,
scheduled: () => [...financeKeys.ap.all, 'scheduled'] as const,
calendar: (month: string) => [...financeKeys.ap.all, 'calendar', month] as const,
},
// Cash Flow
cashFlow: {
all: ['finance', 'cash-flow'] as const,
actual: (period: string) => [...financeKeys.cashFlow.all, 'actual', period] as const,
projected: () => [...financeKeys.cashFlow.all, 'projected'] as const,
byCategory: (period: string) => [...financeKeys.cashFlow.all, 'by-category', period] as const,
monthly: (year: number) => [...financeKeys.cashFlow.all, 'monthly', year] as const,
},
// Invoices
invoices: {
all: ['finance', 'invoices'] as const,
list: (filters?: InvoiceFilters) => [...financeKeys.invoices.all, 'list', filters] as const,
detail: (id: string) => [...financeKeys.invoices.all, 'detail', id] as const,
stats: () => [...financeKeys.invoices.all, 'stats'] as const,
dashboard: () => [...financeKeys.invoices.all, 'dashboard'] as const,
},
};
// ============================================================================
// ERROR HANDLER
// ============================================================================
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
// ============================================================================
// ACCOUNTS HOOKS
// ============================================================================
export function useAccounts(filters?: AccountFilters) {
return useQuery({
queryKey: financeKeys.accounts.list(filters),
queryFn: () => accountsApi.list(filters),
});
}
export function useAccountsTree() {
return useQuery({
queryKey: financeKeys.accounts.tree(),
queryFn: () => accountsApi.tree(),
});
}
export function useAccount(id: string) {
return useQuery({
queryKey: financeKeys.accounts.detail(id),
queryFn: () => accountsApi.get(id),
enabled: !!id,
});
}
export function useCreateAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAccountDto) => accountsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.accounts.all });
toast.success('Cuenta creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateAccountDto }) =>
accountsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.accounts.all });
queryClient.invalidateQueries({ queryKey: financeKeys.accounts.detail(id) });
toast.success('Cuenta actualizada');
},
onError: handleError,
});
}
export function useDeleteAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => accountsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.accounts.all });
toast.success('Cuenta eliminada');
},
onError: handleError,
});
}
// ============================================================================
// ENTRIES HOOKS
// ============================================================================
export function useEntries(filters?: EntryFilters) {
return useQuery({
queryKey: financeKeys.entries.list(filters),
queryFn: () => entriesApi.list(filters),
});
}
export function useEntry(id: string) {
return useQuery({
queryKey: financeKeys.entries.detail(id),
queryFn: () => entriesApi.get(id),
enabled: !!id,
});
}
export function useCreateEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateEntryDto) => entriesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
toast.success('Póliza creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateEntryDto }) =>
entriesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) });
toast.success('Póliza actualizada');
},
onError: handleError,
});
}
export function useDeleteEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => entriesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
toast.success('Póliza eliminada');
},
onError: handleError,
});
}
export function useSubmitEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => entriesApi.submit(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) });
toast.success('Póliza enviada a revisión');
},
onError: handleError,
});
}
export function useApproveEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => entriesApi.approve(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) });
toast.success('Póliza aprobada');
},
onError: handleError,
});
}
export function usePostEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => entriesApi.post(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) });
queryClient.invalidateQueries({ queryKey: financeKeys.reports.all });
toast.success('Póliza contabilizada');
},
onError: handleError,
});
}
export function useCancelEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => entriesApi.cancel(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.entries.detail(id) });
toast.success('Póliza cancelada');
},
onError: handleError,
});
}
export function useReverseEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => entriesApi.reverse(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.reports.all });
toast.success('Póliza reversada');
},
onError: handleError,
});
}
// ============================================================================
// REPORTS HOOKS
// ============================================================================
export function useTrialBalance(period: string) {
return useQuery({
queryKey: financeKeys.reports.trialBalance(period),
queryFn: () => accountingReportsApi.trialBalance(period),
enabled: !!period,
});
}
export function useAccountLedger(accountId: string, period: string) {
return useQuery({
queryKey: financeKeys.reports.ledger(accountId, period),
queryFn: () => accountingReportsApi.accountLedger(accountId, period),
enabled: !!accountId && !!period,
});
}
// ============================================================================
// AR HOOKS
// ============================================================================
export function useAR(filters?: ARFilters) {
return useQuery({
queryKey: financeKeys.ar.list(filters),
queryFn: () => arApi.list(filters),
});
}
export function useARDetail(id: string) {
return useQuery({
queryKey: financeKeys.ar.detail(id),
queryFn: () => arApi.get(id),
enabled: !!id,
});
}
export function useARStats() {
return useQuery({
queryKey: financeKeys.ar.stats(),
queryFn: () => arApi.stats(),
});
}
export function useARAging() {
return useQuery({
queryKey: financeKeys.ar.aging(),
queryFn: () => arApi.aging(),
});
}
export function useRegisterARPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: RegisterARPaymentDto }) =>
arApi.registerPayment(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.ar.all });
queryClient.invalidateQueries({ queryKey: financeKeys.ar.detail(id) });
toast.success('Pago registrado exitosamente');
},
onError: handleError,
});
}
export function useSendARReminder() {
return useMutation({
mutationFn: (id: string) => arApi.sendReminder(id),
onSuccess: () => {
toast.success('Recordatorio enviado');
},
onError: handleError,
});
}
// ============================================================================
// AP HOOKS
// ============================================================================
export function useAP(filters?: APFilters) {
return useQuery({
queryKey: financeKeys.ap.list(filters),
queryFn: () => apApi.list(filters),
});
}
export function useAPDetail(id: string) {
return useQuery({
queryKey: financeKeys.ap.detail(id),
queryFn: () => apApi.get(id),
enabled: !!id,
});
}
export function useAPStats() {
return useQuery({
queryKey: financeKeys.ap.stats(),
queryFn: () => apApi.stats(),
});
}
export function useAPAging() {
return useQuery({
queryKey: financeKeys.ap.aging(),
queryFn: () => apApi.aging(),
});
}
export function useAPScheduled() {
return useQuery({
queryKey: financeKeys.ap.scheduled(),
queryFn: () => apApi.getScheduled(),
});
}
export function useAPCalendar(month: string) {
return useQuery({
queryKey: financeKeys.ap.calendar(month),
queryFn: () => apApi.getCalendar(month),
enabled: !!month,
});
}
export function useScheduleAPPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: ScheduleAPPaymentDto }) =>
apApi.schedule(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.ap.all });
queryClient.invalidateQueries({ queryKey: financeKeys.ap.detail(id) });
toast.success('Pago programado');
},
onError: handleError,
});
}
export function useRegisterAPPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: RegisterAPPaymentDto }) =>
apApi.registerPayment(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.ap.all });
queryClient.invalidateQueries({ queryKey: financeKeys.ap.detail(id) });
queryClient.invalidateQueries({ queryKey: financeKeys.cashFlow.all });
toast.success('Pago registrado exitosamente');
},
onError: handleError,
});
}
// ============================================================================
// CASH FLOW HOOKS
// ============================================================================
export function useCashFlowActual(period: string) {
return useQuery({
queryKey: financeKeys.cashFlow.actual(period),
queryFn: () => cashFlowApi.actual(period),
enabled: !!period,
});
}
export function useCashFlowProjected() {
return useQuery({
queryKey: financeKeys.cashFlow.projected(),
queryFn: () => cashFlowApi.projected(),
});
}
export function useCashFlowByCategory(period: string) {
return useQuery({
queryKey: financeKeys.cashFlow.byCategory(period),
queryFn: () => cashFlowApi.byCategory(period),
enabled: !!period,
});
}
export function useCashFlowMonthly(year: number) {
return useQuery({
queryKey: financeKeys.cashFlow.monthly(year),
queryFn: () => cashFlowApi.monthly(year),
enabled: !!year,
});
}
// ============================================================================
// INVOICES HOOKS
// ============================================================================
export function useInvoices(filters?: InvoiceFilters) {
return useQuery({
queryKey: financeKeys.invoices.list(filters),
queryFn: () => invoicesApi.list(filters),
});
}
export function useInvoice(id: string) {
return useQuery({
queryKey: financeKeys.invoices.detail(id),
queryFn: () => invoicesApi.get(id),
enabled: !!id,
});
}
export function useInvoiceStats() {
return useQuery({
queryKey: financeKeys.invoices.stats(),
queryFn: () => invoicesApi.stats(),
});
}
export function useInvoiceDashboard() {
return useQuery({
queryKey: financeKeys.invoices.dashboard(),
queryFn: () => invoicesApi.dashboard(),
});
}
export function useCreateInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateInvoiceDto) => invoicesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
toast.success('Factura creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateInvoiceDto }) =>
invoicesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) });
toast.success('Factura actualizada');
},
onError: handleError,
});
}
export function useDeleteInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => invoicesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
toast.success('Factura eliminada');
},
onError: handleError,
});
}
export function useSendInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => invoicesApi.send(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) });
toast.success('Factura enviada');
},
onError: handleError,
});
}
export function useMarkInvoicePaid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data?: { date: string; amount: number; method: string; reference?: string } }) =>
invoicesApi.markPaid(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) });
queryClient.invalidateQueries({ queryKey: financeKeys.ar.all });
toast.success('Factura marcada como pagada');
},
onError: handleError,
});
}
export function useCancelInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
invoicesApi.cancel(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) });
toast.success('Factura cancelada');
},
onError: handleError,
});
}
export function useStampInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => invoicesApi.stamp(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.all });
queryClient.invalidateQueries({ queryKey: financeKeys.invoices.detail(id) });
toast.success('Factura timbrada exitosamente');
},
onError: handleError,
});
}
export function useDownloadInvoicePdf() {
return useMutation({
mutationFn: (id: string) => invoicesApi.getPdf(id),
onError: handleError,
});
}
// ============================================================================
// ALIASES FOR BACKWARD COMPATIBILITY
// ============================================================================
// Entries aliases
export const useAccountingEntries = useEntries;
export const useDeleteAccountingEntry = useDeleteEntry;
export const usePostAccountingEntry = usePostEntry;
export function useReverseAccountingEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id }: { id: string; reason?: string }) => entriesApi.reverse(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: financeKeys.entries.all });
queryClient.invalidateQueries({ queryKey: financeKeys.reports.all });
toast.success('Póliza reversada');
},
onError: handleError,
});
}
// Cash Flow aliases
export function useCashFlowSummary(period: string) {
return useQuery({
queryKey: [...financeKeys.cashFlow.all, 'summary', period] as const,
queryFn: () => cashFlowApi.actual(period),
enabled: !!period,
});
}
export function useCashFlowForecast(days: number) {
return useQuery({
queryKey: [...financeKeys.cashFlow.all, 'forecast', days] as const,
queryFn: () => cashFlowApi.projected(Math.ceil(days / 30)),
enabled: !!days,
});
}

View File

@ -23,6 +23,7 @@ import {
CreateGeneradorDto, CreateGeneradorDto,
UpdateGeneradorDto, UpdateGeneradorDto,
} from '../services/presupuestos'; } from '../services/presupuestos';
import { mockConceptos, mockPresupuestos, mockEstimaciones } from '../services/mockData.modules';
// ==================== QUERY KEYS ==================== // ==================== QUERY KEYS ====================
@ -54,17 +55,44 @@ const handleError = (error: AxiosError<ApiError>) => {
// ==================== CONCEPTOS ==================== // ==================== CONCEPTOS ====================
export function useConceptos(filters?: ConceptoFilters) { export function useConceptos(filters?: ConceptoFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: presupuestosKeys.conceptos.list(filters), queryKey: presupuestosKeys.conceptos.list(filters),
queryFn: () => conceptosApi.list(filters), queryFn: async () => {
try {
return await conceptosApi.list(filters);
} catch {
console.warn('[useConceptos] API failed, using mock data');
let items = [...mockConceptos];
if (filters?.tipo) {
items = items.filter(c => c.tipo === filters.tipo);
}
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
items = items.filter(c =>
c.descripcion.toLowerCase().includes(searchLower) ||
c.codigo.toLowerCase().includes(searchLower)
);
}
return { items, total: items.length };
}
},
}); });
} }
export function useConcepto(id: string) { export function useConcepto(id: string) {
return useQuery({ return useQuery({
queryKey: presupuestosKeys.conceptos.detail(id), queryKey: presupuestosKeys.conceptos.detail(id),
queryFn: () => conceptosApi.get(id), queryFn: async () => {
try {
return await conceptosApi.get(id);
} catch {
console.warn('[useConcepto] API failed, using mock data');
const item = mockConceptos.find(c => c.id === id);
if (!item) throw new Error('Not found');
return item;
}
},
enabled: !!id, enabled: !!id,
}); });
} }
@ -72,7 +100,20 @@ export function useConcepto(id: string) {
export function useConceptosTree(rootId?: string) { export function useConceptosTree(rootId?: string) {
return useQuery({ return useQuery({
queryKey: presupuestosKeys.conceptos.tree(rootId), queryKey: presupuestosKeys.conceptos.tree(rootId),
queryFn: () => conceptosApi.getTree(rootId), queryFn: async () => {
try {
return await conceptosApi.getTree(rootId);
} catch {
console.warn('[useConceptosTree] API failed, using mock data');
// Return a simple tree structure from mock data
return mockConceptos
.filter(c => !c.parentId)
.map(c => ({
...c,
children: mockConceptos.filter(child => child.parentId === c.id),
}));
}
},
}); });
} }
@ -116,10 +157,28 @@ export function useDeleteConcepto() {
// ==================== PRESUPUESTOS ==================== // ==================== PRESUPUESTOS ====================
export function usePresupuestos(filters?: PresupuestoFilters) { export function usePresupuestos(filters?: PresupuestoFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: presupuestosKeys.presupuestos.list(filters), queryKey: presupuestosKeys.presupuestos.list(filters),
queryFn: () => presupuestosApi.list(filters), queryFn: async () => {
try {
return await presupuestosApi.list(filters);
} catch {
console.warn('[usePresupuestos] API failed, using mock data');
let items = [...mockPresupuestos];
if (filters?.estado) {
items = items.filter(p => p.estado === filters.estado);
}
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
items = items.filter(p =>
p.nombre.toLowerCase().includes(searchLower) ||
p.codigo.toLowerCase().includes(searchLower)
);
}
return { items, total: items.length };
}
},
}); });
} }
@ -322,10 +381,24 @@ export function useExportPresupuestoExcel() {
// ==================== ESTIMACIONES ==================== // ==================== ESTIMACIONES ====================
export function useEstimaciones(filters?: EstimacionFilters) { export function useEstimaciones(filters?: EstimacionFilters & { search?: string }) {
return useQuery({ return useQuery({
queryKey: presupuestosKeys.estimaciones.list(filters), queryKey: presupuestosKeys.estimaciones.list(filters),
queryFn: () => estimacionesApi.list(filters), queryFn: async () => {
try {
return await estimacionesApi.list(filters);
} catch {
console.warn('[useEstimaciones] API failed, using mock data');
let items = [...mockEstimaciones];
if (filters?.presupuestoId) {
items = items.filter(e => e.presupuestoId === filters.presupuestoId);
}
if (filters?.estado) {
items = items.filter(e => e.estado === filters.estado);
}
return { items, total: items.length };
}
},
}); });
} }

View File

@ -8,6 +8,14 @@ import {
ProjectSummaryFilters, ProjectSummaryFilters,
AlertFilters, AlertFilters,
} from '../services/reports'; } from '../services/reports';
import {
mockDashboardStats,
mockProjectsSummary,
mockAlerts,
getMockProjectKPIs,
getMockEarnedValue,
getMockSCurveData,
} from '../services/mockData';
// ============================================================================= // =============================================================================
// Query Keys Factory // Query Keys Factory
@ -43,11 +51,19 @@ const handleError = (error: AxiosError<ApiError>) => {
/** /**
* Get Earned Value metrics for a specific project * Get Earned Value metrics for a specific project
* Returns SPI, CPI, EV, PV, AC, and other EVM indicators * Returns SPI, CPI, EV, PV, AC, and other EVM indicators
* Falls back to mock data on error
*/ */
export function useEarnedValue(projectId: string, params?: DateRangeParams) { export function useEarnedValue(projectId: string, params?: DateRangeParams) {
return useQuery({ return useQuery({
queryKey: reportsKeys.earnedValue(projectId, params), queryKey: reportsKeys.earnedValue(projectId, params),
queryFn: () => reportsApi.getEarnedValue(projectId, params), queryFn: async () => {
try {
return await reportsApi.getEarnedValue(projectId, params);
} catch {
console.warn('[useEarnedValue] API failed, using mock data');
return getMockEarnedValue(projectId);
}
},
enabled: !!projectId, enabled: !!projectId,
}); });
} }
@ -55,11 +71,19 @@ export function useEarnedValue(projectId: string, params?: DateRangeParams) {
/** /**
* Get S-Curve data for a specific project * Get S-Curve data for a specific project
* Returns time series data for planned vs actual progress * Returns time series data for planned vs actual progress
* Falls back to mock data on error
*/ */
export function useSCurveData(projectId: string, params?: DateRangeParams) { export function useSCurveData(projectId: string, params?: DateRangeParams) {
return useQuery({ return useQuery({
queryKey: reportsKeys.sCurve(projectId, params), queryKey: reportsKeys.sCurve(projectId, params),
queryFn: () => reportsApi.getSCurveData(projectId, params), queryFn: async () => {
try {
return await reportsApi.getSCurveData(projectId, params);
} catch {
console.warn('[useSCurveData] API failed, using mock data');
return getMockSCurveData(projectId);
}
},
enabled: !!projectId, enabled: !!projectId,
}); });
} }
@ -71,32 +95,61 @@ export function useSCurveData(projectId: string, params?: DateRangeParams) {
/** /**
* Get summary of all projects with their KPIs * Get summary of all projects with their KPIs
* Supports filtering by status and search * Supports filtering by status and search
* Falls back to mock data on error
*/ */
export function useProjectsSummary(filters?: ProjectSummaryFilters) { export function useProjectsSummary(filters?: ProjectSummaryFilters) {
return useQuery({ return useQuery({
queryKey: reportsKeys.projectsSummary(filters), queryKey: reportsKeys.projectsSummary(filters),
queryFn: () => reportsApi.getProjectsSummary(filters), queryFn: async () => {
try {
return await reportsApi.getProjectsSummary(filters);
} catch {
// Return mock data on error
console.warn('[useProjectsSummary] API failed, using mock data');
let items = mockProjectsSummary;
if (filters?.status) {
items = items.filter(p => p.status === filters.status);
}
return { items, total: items.length };
}
},
}); });
} }
/** /**
* Get general dashboard statistics * Get general dashboard statistics
* Returns aggregate metrics for all projects * Returns aggregate metrics for all projects
* Falls back to mock data on error
*/ */
export function useDashboardStats() { export function useDashboardStats() {
return useQuery({ return useQuery({
queryKey: reportsKeys.dashboardStats(), queryKey: reportsKeys.dashboardStats(),
queryFn: () => reportsApi.getDashboardStats(), queryFn: async () => {
try {
return await reportsApi.getDashboardStats();
} catch {
console.warn('[useDashboardStats] API failed, using mock data');
return mockDashboardStats;
}
},
}); });
} }
/** /**
* Get KPIs for a specific project * Get KPIs for a specific project
* Falls back to mock data on error
*/ */
export function useProjectKPIs(projectId: string, params?: DateRangeParams) { export function useProjectKPIs(projectId: string, params?: DateRangeParams) {
return useQuery({ return useQuery({
queryKey: reportsKeys.projectKPIs(projectId, params), queryKey: reportsKeys.projectKPIs(projectId, params),
queryFn: () => reportsApi.getProjectKPIs(projectId, params), queryFn: async () => {
try {
return await reportsApi.getProjectKPIs(projectId, params);
} catch {
console.warn('[useProjectKPIs] API failed, using mock data');
return getMockProjectKPIs(projectId);
}
},
enabled: !!projectId, enabled: !!projectId,
}); });
} }
@ -108,11 +161,33 @@ export function useProjectKPIs(projectId: string, params?: DateRangeParams) {
/** /**
* Get active alerts * Get active alerts
* Supports filtering by severity, project, and type * Supports filtering by severity, project, and type
* Falls back to mock data on error
*/ */
export function useAlerts(filters?: AlertFilters) { export function useAlerts(filters?: AlertFilters) {
return useQuery({ return useQuery({
queryKey: reportsKeys.alerts(filters), queryKey: reportsKeys.alerts(filters),
queryFn: () => reportsApi.getAlerts(filters), queryFn: async () => {
try {
return await reportsApi.getAlerts(filters);
} catch {
console.warn('[useAlerts] API failed, using mock data');
let items = [...mockAlerts];
if (filters?.acknowledged !== undefined) {
// acknowledged=false means filter for items without acknowledgedAt
// acknowledged=true means filter for items with acknowledgedAt
items = items.filter(a =>
filters.acknowledged ? !!a.acknowledgedAt : !a.acknowledgedAt
);
}
if (filters?.severity) {
items = items.filter(a => a.severity === filters.severity);
}
if (filters?.projectId) {
items = items.filter(a => a.projectId === filters.projectId);
}
return { items, total: items.length };
}
},
}); });
} }

160
web/src/hooks/useSession.ts Normal file
View File

@ -0,0 +1,160 @@
/**
* useSession Hook
* Manages session lifecycle, automatic refresh, and expiration warnings
* Based on gamilit implementation
*/
import { useEffect, useCallback, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { refreshToken as refreshTokenApi } from '../services/auth';
import toast from 'react-hot-toast';
// Check session every minute
const SESSION_CHECK_INTERVAL = 60 * 1000;
// Warn user 5 minutes before expiration
const SESSION_WARNING_THRESHOLD = 5 * 60 * 1000;
// Refresh token if less than 1 hour remaining
const TOKEN_REFRESH_THRESHOLD = 60 * 60 * 1000;
interface UseSessionOptions {
/** Enable automatic session refresh */
autoRefresh?: boolean;
/** Show warning before session expires */
showExpirationWarning?: boolean;
/** Callback when session expires */
onSessionExpired?: () => void;
}
export function useSession(options: UseSessionOptions = {}) {
const {
autoRefresh = true,
showExpirationWarning = true,
onSessionExpired,
} = options;
const {
isAuthenticated,
sessionExpiresAt,
refreshToken,
checkSession,
setTokens,
extendSession,
logout,
} = useAuthStore();
const warningShownRef = useRef(false);
const isRefreshingRef = useRef(false);
// Refresh the session token
const refreshSession = useCallback(async () => {
if (isRefreshingRef.current || !refreshToken) {
return false;
}
isRefreshingRef.current = true;
try {
const response = await refreshTokenApi(refreshToken);
setTokens(response.accessToken, response.refreshToken);
warningShownRef.current = false;
return true;
} catch (error) {
console.error('[useSession] Token refresh failed:', error);
return false;
} finally {
isRefreshingRef.current = false;
}
}, [refreshToken, setTokens]);
// Handle session expiration
const handleSessionExpired = useCallback(() => {
toast.error('Tu sesión ha expirado. Por favor, inicia sesión nuevamente.');
logout();
onSessionExpired?.();
}, [logout, onSessionExpired]);
// Check session and refresh if needed
useEffect(() => {
if (!isAuthenticated) {
return;
}
const checkAndRefresh = async () => {
// Validate session
const isValid = checkSession();
if (!isValid) {
handleSessionExpired();
return;
}
// Calculate time until expiration
if (sessionExpiresAt) {
const timeUntilExpiry = sessionExpiresAt - Date.now();
// Show warning if about to expire
if (
showExpirationWarning &&
timeUntilExpiry <= SESSION_WARNING_THRESHOLD &&
timeUntilExpiry > 0 &&
!warningShownRef.current
) {
warningShownRef.current = true;
const minutesLeft = Math.ceil(timeUntilExpiry / 60000);
toast(
`Tu sesión expirará en ${minutesLeft} minutos. Guarda tu trabajo.`,
{
icon: '⚠️',
duration: 10000,
}
);
}
// Auto-refresh if threshold reached
if (autoRefresh && timeUntilExpiry <= TOKEN_REFRESH_THRESHOLD && timeUntilExpiry > 0) {
const success = await refreshSession();
if (!success) {
// Refresh failed - session will expire
console.warn('[useSession] Auto-refresh failed');
}
}
}
};
// Initial check
checkAndRefresh();
// Set up interval for periodic checks
const interval = setInterval(checkAndRefresh, SESSION_CHECK_INTERVAL);
return () => {
clearInterval(interval);
};
}, [
isAuthenticated,
sessionExpiresAt,
checkSession,
refreshSession,
handleSessionExpired,
autoRefresh,
showExpirationWarning,
]);
// Reset warning flag when session is extended
useEffect(() => {
warningShownRef.current = false;
}, [sessionExpiresAt]);
return {
isAuthenticated,
sessionExpiresAt,
checkSession,
refreshSession,
extendSession,
timeUntilExpiry: sessionExpiresAt ? sessionExpiresAt - Date.now() : null,
};
}
export default useSession;

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation } from 'react-router-dom';
import { import {
Building2, Building2,
@ -9,7 +9,6 @@ import {
Menu, Menu,
X, X,
LogOut, LogOut,
User,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
LayoutDashboard, LayoutDashboard,
@ -31,10 +30,15 @@ import {
Wallet, Wallet,
PiggyBank, PiggyBank,
ArrowLeftRight, ArrowLeftRight,
Settings,
UserCircle,
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { useLogout } from '../hooks/useAuth';
import { useSession } from '../hooks/useSession';
import { ThemeToggle } from '../components/theme'; import { ThemeToggle } from '../components/theme';
import { LoadingSpinner } from '../components/common/LoadingSpinner';
interface NavItem { interface NavItem {
label: string; label: string;
@ -130,11 +134,66 @@ export function AdminLayout() {
return initial; return initial;
}); });
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuthStore(); const user = useAuthStore((state) => state.user);
const userMenuRef = useRef<HTMLDivElement>(null);
// Use logout hook for proper cleanup
const logoutMutation = useLogout();
// Initialize session management
useSession({
autoRefresh: true,
showExpirationWarning: true,
});
const handleLogout = () => { const handleLogout = () => {
logout(); setUserMenuOpen(false);
window.location.href = '/auth/login'; logoutMutation.mutate();
};
// Close user menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setUserMenuOpen(false);
}
};
if (userMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [userMenuOpen]);
// Close user menu on escape key
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setUserMenuOpen(false);
}
};
if (userMenuOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [userMenuOpen]);
// Get user initials for avatar
const getUserInitials = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
}
if (user?.email) {
return user.email.substring(0, 2).toUpperCase();
}
return 'U';
}; };
const toggleSection = (title: string) => { const toggleSection = (title: string) => {
@ -149,7 +208,7 @@ export function AdminLayout() {
}; };
return ( return (
<div className="min-h-screen bg-background-subtle dark:bg-background transition-colors duration-200"> <div className="min-h-screen bg-background-subtle dark:bg-background transition-colors duration-200 lg:flex">
{/* Mobile sidebar backdrop */} {/* Mobile sidebar backdrop */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
@ -161,7 +220,7 @@ export function AdminLayout() {
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={clsx( className={clsx(
'fixed inset-y-0 left-0 z-30 w-64 bg-surface-card dark:bg-surface-card shadow-lg transform transition-all duration-300 lg:translate-x-0 lg:static', 'fixed inset-y-0 left-0 z-30 w-64 bg-surface-card dark:bg-surface-card shadow-lg transform transition-all duration-300 lg:translate-x-0 lg:static lg:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full' sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)} )}
> >
@ -233,7 +292,7 @@ export function AdminLayout() {
</aside> </aside>
{/* Main content */} {/* Main content */}
<div className="lg:pl-64"> <div className="flex-1 min-w-0">
{/* Top bar */} {/* Top bar */}
<header className="sticky top-0 z-10 bg-surface-card dark:bg-surface-card shadow-sm border-b border-border dark:border-border"> <header className="sticky top-0 z-10 bg-surface-card dark:bg-surface-card shadow-sm border-b border-border dark:border-border">
<div className="flex items-center justify-between h-16 px-4"> <div className="flex items-center justify-between h-16 px-4">
@ -251,32 +310,90 @@ export function AdminLayout() {
<ThemeToggle simple size="md" className="mr-2" /> <ThemeToggle simple size="md" className="mr-2" />
{/* User menu */} {/* User menu */}
<div className="relative"> <div className="relative" ref={userMenuRef}>
<button <button
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors" className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors"
onClick={() => setUserMenuOpen(!userMenuOpen)} onClick={() => setUserMenuOpen(!userMenuOpen)}
aria-label="Menú de usuario" aria-label="Menú de usuario"
aria-expanded={userMenuOpen} aria-expanded={userMenuOpen}
> >
<div className="w-8 h-8 bg-primary/10 dark:bg-primary/20 rounded-full flex items-center justify-center"> {/* Avatar with initials */}
<User className="w-5 h-5 text-primary" /> <div className="w-8 h-8 bg-gradient-to-br from-primary to-primary-600 rounded-full flex items-center justify-center">
</div> <span className="text-white text-sm font-semibold">
<span className="hidden sm:block text-sm font-medium text-foreground"> {getUserInitials()}
{user?.firstName || user?.email || 'Usuario'}
</span> </span>
<ChevronDown className="w-4 h-4 text-foreground-muted" /> </div>
{/* User info */}
<div className="hidden md:block text-left">
<p className="text-sm font-medium text-foreground dark:text-foreground">
{user?.firstName || user?.email?.split('@')[0] || 'Usuario'}
</p>
<p className="text-xs text-foreground-muted dark:text-foreground-muted capitalize">
{user?.role || 'Usuario'}
</p>
</div>
<ChevronDown className={clsx(
'w-4 h-4 text-foreground-muted transition-transform',
userMenuOpen && 'rotate-180'
)} />
</button> </button>
{/* Dropdown menu */}
{userMenuOpen && ( {userMenuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-surface-popover dark:bg-surface-popover rounded-lg shadow-lg border border-border py-1 z-dropdown"> <div className="absolute right-0 mt-2 w-56 bg-surface-popover dark:bg-surface-popover rounded-lg shadow-lg border border-border dark:border-border py-1 z-dropdown">
<button {/* User info header */}
className="flex items-center w-full px-4 py-2 text-sm text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors" <div className="px-4 py-3 border-b border-border dark:border-border">
onClick={handleLogout} <p className="text-sm font-medium text-foreground dark:text-foreground">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-foreground-muted dark:text-foreground-muted truncate">
{user?.email}
</p>
</div>
{/* Menu items */}
<div className="py-1">
<Link
to="/admin/perfil"
onClick={() => setUserMenuOpen(false)}
className="flex items-center w-full px-4 py-2.5 text-sm text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors"
> >
<LogOut className="w-4 h-4 mr-2" /> <UserCircle className="w-4 h-4 mr-3 text-foreground-muted" />
Cerrar Sesion Mi Perfil
</Link>
<Link
to="/admin/configuracion"
onClick={() => setUserMenuOpen(false)}
className="flex items-center w-full px-4 py-2.5 text-sm text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors"
>
<Settings className="w-4 h-4 mr-3 text-foreground-muted" />
Configuración
</Link>
</div>
{/* Logout */}
<div className="border-t border-border dark:border-border py-1">
<button
onClick={handleLogout}
disabled={logoutMutation.isPending}
className="flex items-center w-full px-4 py-2.5 text-sm text-danger hover:bg-danger/10 dark:hover:bg-danger/20 transition-colors disabled:opacity-50"
>
{logoutMutation.isPending ? (
<>
<LoadingSpinner size="sm" className="mr-3" />
Cerrando sesión...
</>
) : (
<>
<LogOut className="w-4 h-4 mr-3" />
Cerrar Sesión
</>
)}
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,497 @@
/**
* CuentasContablesPage - Catálogo de Cuentas Contables
*/
import { useState } from 'react';
import { Plus, Pencil, Trash2, ChevronRight, ChevronDown, FolderTree } from 'lucide-react';
import {
useAccountsTree,
useCreateAccount,
useUpdateAccount,
useDeleteAccount,
} from '../../../hooks/useFinance';
import type { Account, AccountType, CreateAccountDto } from '../../../types/finance.types';
import { ACCOUNT_TYPE_OPTIONS } from '../../../types/finance.types';
import {
PageHeader,
PageHeaderAction,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
Modal,
ModalFooter,
TextInput,
FormGroup,
LoadingOverlay,
EmptyState,
} from '../../../components/common';
export function CuentasContablesPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<AccountType | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Account | null>(null);
const [parentAccount, setParentAccount] = useState<Account | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const { data: accounts, isLoading, error } = useAccountsTree();
const createMutation = useCreateAccount();
const updateMutation = useUpdateAccount();
const deleteMutation = useDeleteAccount();
const toggleExpand = (id: string) => {
const newExpanded = new Set(expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedIds(newExpanded);
};
const expandAll = () => {
const allIds = new Set<string>();
const collectIds = (items: Account[]) => {
items.forEach(item => {
if (item.children?.length) {
allIds.add(item.id);
collectIds(item.children);
}
});
};
if (accounts) collectIds(accounts);
setExpandedIds(allIds);
};
const collapseAll = () => {
setExpandedIds(new Set());
};
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleSubmit = async (formData: CreateAccountDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
setParentAccount(null);
};
const openCreate = (parent?: Account) => {
setEditingItem(null);
setParentAccount(parent || null);
setShowModal(true);
};
const openEdit = (item: Account) => {
setEditingItem(item);
setParentAccount(null);
setShowModal(true);
};
// Filter accounts
const filterAccounts = (items: Account[]): Account[] => {
return items.filter(item => {
const matchesSearch = !search ||
item.code.toLowerCase().includes(search.toLowerCase()) ||
item.name.toLowerCase().includes(search.toLowerCase());
const matchesType = !typeFilter || item.type === typeFilter;
if (matchesSearch && matchesType) return true;
// Check children
if (item.children?.length) {
const filteredChildren = filterAccounts(item.children);
if (filteredChildren.length > 0) return true;
}
return false;
}).map(item => ({
...item,
children: item.children ? filterAccounts(item.children) : undefined,
}));
};
const filteredAccounts = accounts ? filterAccounts(accounts) : [];
if (isLoading) {
return <LoadingOverlay message="Cargando catálogo de cuentas..." />;
}
if (error) {
return (
<EmptyState
title="Error al cargar"
description="No se pudo cargar el catálogo de cuentas. Intente de nuevo."
/>
);
}
return (
<div>
<PageHeader
title="Catálogo de Cuentas"
description="Gestión del catálogo de cuentas contables"
actions={
<PageHeaderAction onClick={() => openCreate()}>
<Plus className="w-5 h-5 mr-2" />
Nueva Cuenta
</PageHeaderAction>
}
/>
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-4 flex-1">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por código o nombre..."
className="flex-1 max-w-md"
/>
<SelectField
options={[{ value: '', label: 'Todos los tipos' }, ...ACCOUNT_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label }))]}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as AccountType | '')}
className="sm:w-48"
/>
</div>
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg"
>
Expandir todo
</button>
<button
onClick={collapseAll}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg"
>
Colapsar todo
</button>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm">
{filteredAccounts.length === 0 ? (
<EmptyState
icon={<FolderTree className="w-12 h-12 text-gray-400" />}
title="No hay cuentas"
description="Crea la primera cuenta para comenzar a estructurar tu catálogo."
/>
) : (
<div className="divide-y divide-gray-100">
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-gray-50 text-sm font-medium text-gray-500">
<div className="col-span-5">Cuenta</div>
<div className="col-span-2">Tipo</div>
<div className="col-span-2 text-right">Saldo</div>
<div className="col-span-1 text-center">Estado</div>
<div className="col-span-2 text-right">Acciones</div>
</div>
{filteredAccounts.map(account => (
<AccountRow
key={account.id}
account={account}
level={0}
expandedIds={expandedIds}
onToggle={toggleExpand}
onEdit={openEdit}
onDelete={setDeleteId}
onAddChild={openCreate}
/>
))}
</div>
)}
</div>
{showModal && (
<AccountModal
item={editingItem}
parentAccount={parentAccount}
accounts={accounts || []}
onClose={() => {
setShowModal(false);
setEditingItem(null);
setParentAccount(null);
}}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminación"
message="¿Está seguro de eliminar esta cuenta? Si tiene subcuentas o movimientos, no podrá ser eliminada."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}
// ============================================================================
// ACCOUNT ROW COMPONENT
// ============================================================================
interface AccountRowProps {
account: Account;
level: number;
expandedIds: Set<string>;
onToggle: (id: string) => void;
onEdit: (account: Account) => void;
onDelete: (id: string) => void;
onAddChild: (parent: Account) => void;
}
function AccountRow({
account,
level,
expandedIds,
onToggle,
onEdit,
onDelete,
onAddChild,
}: AccountRowProps) {
const hasChildren = account.children && account.children.length > 0;
const isExpanded = expandedIds.has(account.id);
const paddingLeft = level * 24 + 16;
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
return (
<>
<div className="grid grid-cols-12 gap-4 px-4 py-3 hover:bg-gray-50 items-center">
<div className="col-span-5 flex items-center" style={{ paddingLeft }}>
{hasChildren ? (
<button
onClick={() => onToggle(account.id)}
className="p-1 hover:bg-gray-200 rounded mr-2"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
) : (
<span className="w-6 mr-2" />
)}
<div>
<span className="font-mono text-sm text-gray-500 mr-2">{account.code}</span>
<span className="font-medium text-gray-900">{account.name}</span>
</div>
</div>
<div className="col-span-2">
<StatusBadgeFromOptions value={account.type} options={[...ACCOUNT_TYPE_OPTIONS]} />
</div>
<div className="col-span-2 text-right font-mono text-sm">
{formatCurrency(account.balance)}
</div>
<div className="col-span-1 text-center">
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
account.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{account.isActive ? 'Activa' : 'Inactiva'}
</span>
</div>
<div className="col-span-2 flex items-center justify-end gap-1">
<button
onClick={() => onAddChild(account)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Agregar subcuenta"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => onEdit(account)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(account.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{hasChildren && isExpanded && account.children!.map(child => (
<AccountRow
key={child.id}
account={child}
level={level + 1}
expandedIds={expandedIds}
onToggle={onToggle}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</>
);
}
// ============================================================================
// ACCOUNT MODAL
// ============================================================================
interface AccountModalProps {
item: Account | null;
parentAccount: Account | null;
accounts: Account[];
onClose: () => void;
onSubmit: (data: CreateAccountDto) => Promise<void>;
isLoading: boolean;
}
function AccountModal({ item, parentAccount, accounts, onClose, onSubmit, isLoading }: AccountModalProps) {
const [formData, setFormData] = useState<CreateAccountDto>({
code: item?.code || '',
name: item?.name || '',
type: item?.type || parentAccount?.type || 'asset',
parentId: item?.parentId || parentAccount?.id || undefined,
allowTransactions: item?.allowTransactions ?? true,
isActive: item?.isActive ?? true,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const update = <K extends keyof CreateAccountDto>(field: K, value: CreateAccountDto[K]) => {
setFormData({ ...formData, [field]: value });
};
// Flatten accounts for parent select
const flattenAccounts = (items: Account[], prefix = ''): { value: string; label: string }[] => {
const result: { value: string; label: string }[] = [];
items.forEach(item => {
result.push({ value: item.id, label: `${prefix}${item.code} - ${item.name}` });
if (item.children?.length) {
result.push(...flattenAccounts(item.children, prefix + ' '));
}
});
return result;
};
const parentOptions = [
{ value: '', label: 'Sin cuenta padre (nivel raíz)' },
...flattenAccounts(accounts),
];
return (
<Modal
isOpen={true}
onClose={onClose}
title={item ? 'Editar Cuenta' : parentAccount ? `Nueva Subcuenta de ${parentAccount.code}` : 'Nueva Cuenta'}
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="account-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear Cuenta'}
</button>
</ModalFooter>
}
>
<form id="account-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput
label="Código"
required
value={formData.code}
onChange={(e) => update('code', e.target.value)}
placeholder="1101"
/>
<SelectField
label="Tipo de Cuenta"
required
options={ACCOUNT_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
value={formData.type}
onChange={(e) => update('type', e.target.value as AccountType)}
/>
</FormGroup>
<TextInput
label="Nombre"
required
value={formData.name}
onChange={(e) => update('name', e.target.value)}
placeholder="Caja y Bancos"
/>
<SelectField
label="Cuenta Padre"
options={parentOptions}
value={formData.parentId || ''}
onChange={(e) => update('parentId', e.target.value || undefined)}
/>
<FormGroup cols={2}>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allowTransactions"
checked={formData.allowTransactions}
onChange={(e) => update('allowTransactions', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="allowTransactions" className="text-sm text-gray-700">
Permite movimientos
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => update('isActive', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="isActive" className="text-sm text-gray-700">
Cuenta activa
</label>
</div>
</FormGroup>
</form>
</Modal>
);
}

View File

@ -0,0 +1,472 @@
/**
* CuentasPorCobrarPage - Cuentas por Cobrar (CxC)
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Eye, DollarSign, Send, AlertTriangle, TrendingUp, Clock } from 'lucide-react';
import {
useAR,
useARStats,
useRegisterARPayment,
useSendARReminder,
} from '../../../hooks/useFinance';
import type { AccountsReceivable, ARStatus, RegisterARPaymentDto } from '../../../types/finance.types';
import { AR_STATUS_OPTIONS } from '../../../types/finance.types';
import {
PageHeader,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
Modal,
ModalFooter,
TextInput,
FormGroup,
LoadingOverlay,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
export function CuentasPorCobrarPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<ARStatus | ''>('');
const [overdueOnly, setOverdueOnly] = useState(false);
const [paymentModal, setPaymentModal] = useState<AccountsReceivable | null>(null);
const { data, isLoading, error } = useAR({
status: statusFilter || undefined,
overdue: overdueOnly || undefined,
});
const { data: stats } = useARStats();
const paymentMutation = useRegisterARPayment();
const reminderMutation = useSendARReminder();
const handlePayment = async (formData: RegisterARPaymentDto) => {
if (paymentModal) {
await paymentMutation.mutateAsync({ id: paymentModal.id, data: formData });
setPaymentModal(null);
}
};
const handleSendReminder = async (id: string) => {
await reminderMutation.mutateAsync(id);
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
// Filter by search
const items = (data?.items || []).filter(
(item) => !search ||
item.partnerName.toLowerCase().includes(search.toLowerCase()) ||
item.documentNumber.toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<AccountsReceivable>[] = [
{
key: 'document',
header: 'Documento',
render: (item) => (
<div>
<span className="font-medium text-gray-900">{item.documentNumber}</span>
<p className="text-sm text-gray-500">{item.documentType}</p>
</div>
),
},
{
key: 'partner',
header: 'Cliente',
render: (item) => (
<span className="text-gray-900">{item.partnerName}</span>
),
},
{
key: 'dates',
header: 'Fechas',
render: (item) => (
<div className="text-sm">
<p>Emisión: {formatDate(item.documentDate)}</p>
<p className={item.daysOverdue > 0 ? 'text-red-600 font-medium' : ''}>
Vence: {formatDate(item.dueDate)}
</p>
</div>
),
},
{
key: 'amounts',
header: 'Importes',
align: 'right',
render: (item) => (
<div className="text-sm text-right">
<p className="text-gray-500">Original: {formatCurrency(item.originalAmount)}</p>
<p className="font-medium text-gray-900">Saldo: {formatCurrency(item.balanceAmount)}</p>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (item) => (
<div>
<StatusBadgeFromOptions value={item.status} options={[...AR_STATUS_OPTIONS]} />
{item.daysOverdue > 0 && (
<p className="text-xs text-red-600 mt-1">{item.daysOverdue} días vencido</p>
)}
</div>
),
},
{
key: 'actions',
header: 'Acciones',
align: 'right',
render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/finanzas/cxc/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{item.status !== 'paid' && item.status !== 'cancelled' && (
<>
<button
onClick={() => setPaymentModal(item)}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Registrar pago"
>
<DollarSign className="w-4 h-4" />
</button>
{item.daysOverdue > 0 && (
<button
onClick={() => handleSendReminder(item.id)}
className="p-2 text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg"
title="Enviar recordatorio"
disabled={reminderMutation.isPending}
>
<Send className="w-4 h-4" />
</button>
)}
</>
)}
</div>
),
},
];
if (isLoading) {
return <LoadingOverlay message="Cargando cuentas por cobrar..." />;
}
return (
<div>
<PageHeader
title="Cuentas por Cobrar"
description="Gestión de cuentas por cobrar y cobranza"
/>
{/* KPI Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<KPICard
title="Total Pendiente"
value={formatCurrency(stats.totalPending)}
icon={<TrendingUp className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="Corriente"
value={formatCurrency(stats.current)}
icon={<Clock className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Vencido"
value={formatCurrency(stats.totalOverdue)}
icon={<AlertTriangle className="w-6 h-6" />}
color="red"
/>
<KPICard
title="Documentos"
value={stats.count.toString()}
icon={<DollarSign className="w-6 h-6" />}
color="gray"
/>
</div>
)}
{/* Aging Summary */}
{stats && (
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3">Antigüedad de Saldos</h3>
<div className="grid grid-cols-4 gap-4">
<AgingItem label="Corriente" value={stats.current} total={stats.totalPending} color="green" />
<AgingItem label="1-30 días" value={stats.overdue30} total={stats.totalPending} color="yellow" />
<AgingItem label="31-60 días" value={stats.overdue60} total={stats.totalPending} color="orange" />
<AgingItem label="60+ días" value={stats.overdue90Plus} total={stats.totalPending} color="red" />
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por cliente o documento..."
className="flex-1"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...AR_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }))]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ARStatus | '')}
className="sm:w-48"
/>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={overdueOnly}
onChange={(e) => setOverdueOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Solo vencidos
</label>
</div>
</div>
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{ title: 'No hay cuentas por cobrar', description: 'No se encontraron documentos con los filtros aplicados.' }}
/>
{paymentModal && (
<PaymentModal
item={paymentModal}
onClose={() => setPaymentModal(null)}
onSubmit={handlePayment}
isLoading={paymentMutation.isPending}
/>
)}
</div>
);
}
// ============================================================================
// KPI CARD COMPONENT
// ============================================================================
interface KPICardProps {
title: string;
value: string;
icon: React.ReactNode;
color: 'blue' | 'green' | 'red' | 'gray' | 'yellow' | 'orange';
}
function KPICard({ title, value, icon, color }: KPICardProps) {
const colorClasses = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
gray: 'bg-gray-50 text-gray-600',
yellow: 'bg-yellow-50 text-yellow-600',
orange: 'bg-orange-50 text-orange-600',
};
return (
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-semibold text-gray-900 mt-1">{value}</p>
</div>
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
{icon}
</div>
</div>
</div>
);
}
// ============================================================================
// AGING ITEM COMPONENT
// ============================================================================
interface AgingItemProps {
label: string;
value: number;
total: number;
color: 'green' | 'yellow' | 'orange' | 'red';
}
function AgingItem({ label, value, total, color }: AgingItemProps) {
const percentage = total > 0 ? (value / total) * 100 : 0;
const colorClasses = {
green: 'bg-green-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
red: 'bg-red-500',
};
const formatCurrency = (val: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(val);
};
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{label}</span>
<span className="font-medium">{formatCurrency(value)}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`${colorClasses[color]} h-2 rounded-full`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{percentage.toFixed(1)}%</p>
</div>
);
}
// ============================================================================
// PAYMENT MODAL
// ============================================================================
interface PaymentModalProps {
item: AccountsReceivable;
onClose: () => void;
onSubmit: (data: RegisterARPaymentDto) => Promise<void>;
isLoading: boolean;
}
function PaymentModal({ item, onClose, onSubmit, isLoading }: PaymentModalProps) {
const [formData, setFormData] = useState<RegisterARPaymentDto>({
date: new Date().toISOString().split('T')[0],
amount: item.balanceAmount,
method: 'transfer',
reference: '',
notes: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const update = <K extends keyof RegisterARPaymentDto>(field: K, value: RegisterARPaymentDto[K]) => {
setFormData({ ...formData, [field]: value });
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
return (
<Modal
isOpen={true}
onClose={onClose}
title="Registrar Pago"
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="payment-form"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Registrando...' : 'Registrar Pago'}
</button>
</ModalFooter>
}
>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Documento: <span className="font-medium">{item.documentNumber}</span></p>
<p className="text-sm text-gray-600">Cliente: <span className="font-medium">{item.partnerName}</span></p>
<p className="text-sm text-gray-600">Saldo pendiente: <span className="font-medium text-blue-600">{formatCurrency(item.balanceAmount)}</span></p>
</div>
<form id="payment-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput
label="Fecha de Pago"
type="date"
required
value={formData.date}
onChange={(e) => update('date', e.target.value)}
/>
<TextInput
label="Monto"
type="number"
required
min={0}
max={item.balanceAmount}
step={0.01}
value={formData.amount}
onChange={(e) => update('amount', parseFloat(e.target.value))}
/>
</FormGroup>
<FormGroup cols={2}>
<SelectField
label="Método de Pago"
required
options={[
{ value: 'transfer', label: 'Transferencia' },
{ value: 'cash', label: 'Efectivo' },
{ value: 'check', label: 'Cheque' },
{ value: 'card', label: 'Tarjeta' },
]}
value={formData.method}
onChange={(e) => update('method', e.target.value)}
/>
<TextInput
label="Referencia"
value={formData.reference || ''}
onChange={(e) => update('reference', e.target.value)}
placeholder="No. de transferencia, cheque, etc."
/>
</FormGroup>
<TextInput
label="Notas"
value={formData.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas adicionales..."
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,454 @@
/**
* CuentasPorPagarPage - Cuentas por Pagar (CxP)
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Eye, DollarSign, AlertTriangle, TrendingDown, Clock, FileText } from 'lucide-react';
import {
useAP,
useAPStats,
useRegisterAPPayment,
} from '../../../hooks/useFinance';
import type { AccountsPayable, APStatus, RegisterAPPaymentDto } from '../../../types/finance.types';
import { AP_STATUS_OPTIONS } from '../../../types/finance.types';
import {
PageHeader,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
Modal,
ModalFooter,
TextInput,
FormGroup,
LoadingOverlay,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
export function CuentasPorPagarPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<APStatus | ''>('');
const [overdueOnly, setOverdueOnly] = useState(false);
const [paymentModal, setPaymentModal] = useState<AccountsPayable | null>(null);
const { data, isLoading, error } = useAP({
status: statusFilter || undefined,
overdue: overdueOnly || undefined,
});
const { data: stats } = useAPStats();
const paymentMutation = useRegisterAPPayment();
const handlePayment = async (formData: RegisterAPPaymentDto) => {
if (paymentModal) {
await paymentMutation.mutateAsync({ id: paymentModal.id, data: formData });
setPaymentModal(null);
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
// Filter by search
const items = (data?.items || []).filter(
(item) => !search ||
item.partnerName.toLowerCase().includes(search.toLowerCase()) ||
item.documentNumber.toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<AccountsPayable>[] = [
{
key: 'document',
header: 'Documento',
render: (item) => (
<div>
<span className="font-medium text-gray-900">{item.documentNumber}</span>
<p className="text-sm text-gray-500">{item.documentType}</p>
</div>
),
},
{
key: 'partner',
header: 'Proveedor',
render: (item) => (
<span className="text-gray-900">{item.partnerName}</span>
),
},
{
key: 'dates',
header: 'Fechas',
render: (item) => (
<div className="text-sm">
<p>Recepción: {formatDate(item.documentDate)}</p>
<p className={item.daysOverdue > 0 ? 'text-red-600 font-medium' : ''}>
Vence: {formatDate(item.dueDate)}
</p>
</div>
),
},
{
key: 'amounts',
header: 'Importes',
align: 'right',
render: (item) => (
<div className="text-sm text-right">
<p className="text-gray-500">Original: {formatCurrency(item.originalAmount)}</p>
<p className="font-medium text-gray-900">Saldo: {formatCurrency(item.balanceAmount)}</p>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (item) => (
<div>
<StatusBadgeFromOptions value={item.status} options={[...AP_STATUS_OPTIONS]} />
{item.daysOverdue > 0 && (
<p className="text-xs text-red-600 mt-1">{item.daysOverdue} días vencido</p>
)}
</div>
),
},
{
key: 'actions',
header: 'Acciones',
align: 'right',
render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/finanzas/cxp/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{item.status !== 'paid' && item.status !== 'cancelled' && (
<button
onClick={() => setPaymentModal(item)}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Registrar pago"
>
<DollarSign className="w-4 h-4" />
</button>
)}
</div>
),
},
];
if (isLoading) {
return <LoadingOverlay message="Cargando cuentas por pagar..." />;
}
return (
<div>
<PageHeader
title="Cuentas por Pagar"
description="Gestión de cuentas por pagar a proveedores"
/>
{/* KPI Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<KPICard
title="Total Pendiente"
value={formatCurrency(stats.totalPending)}
icon={<TrendingDown className="w-6 h-6" />}
color="blue"
/>
<KPICard
title="Corriente"
value={formatCurrency(stats.current)}
icon={<Clock className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Vencido"
value={formatCurrency(stats.totalOverdue)}
icon={<AlertTriangle className="w-6 h-6" />}
color="red"
/>
<KPICard
title="Documentos"
value={stats.count.toString()}
icon={<FileText className="w-6 h-6" />}
color="gray"
/>
</div>
)}
{/* Aging Summary */}
{stats && (
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3">Antigüedad de Saldos</h3>
<div className="grid grid-cols-4 gap-4">
<AgingItem label="Corriente" value={stats.current} total={stats.totalPending} color="green" />
<AgingItem label="1-30 días" value={stats.overdue30} total={stats.totalPending} color="yellow" />
<AgingItem label="31-60 días" value={stats.overdue60} total={stats.totalPending} color="orange" />
<AgingItem label="60+ días" value={stats.overdue90Plus} total={stats.totalPending} color="red" />
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por proveedor o documento..."
className="flex-1"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...AP_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }))]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as APStatus | '')}
className="sm:w-48"
/>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={overdueOnly}
onChange={(e) => setOverdueOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Solo vencidos
</label>
</div>
</div>
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{ title: 'No hay cuentas por pagar', description: 'No se encontraron documentos con los filtros aplicados.' }}
/>
{paymentModal && (
<PaymentModal
item={paymentModal}
onClose={() => setPaymentModal(null)}
onSubmit={handlePayment}
isLoading={paymentMutation.isPending}
/>
)}
</div>
);
}
// ============================================================================
// KPI CARD COMPONENT
// ============================================================================
interface KPICardProps {
title: string;
value: string;
icon: React.ReactNode;
color: 'blue' | 'green' | 'red' | 'gray' | 'yellow' | 'orange';
}
function KPICard({ title, value, icon, color }: KPICardProps) {
const colorClasses = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
gray: 'bg-gray-50 text-gray-600',
yellow: 'bg-yellow-50 text-yellow-600',
orange: 'bg-orange-50 text-orange-600',
};
return (
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-semibold text-gray-900 mt-1">{value}</p>
</div>
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
{icon}
</div>
</div>
</div>
);
}
// ============================================================================
// AGING ITEM COMPONENT
// ============================================================================
interface AgingItemProps {
label: string;
value: number;
total: number;
color: 'green' | 'yellow' | 'orange' | 'red';
}
function AgingItem({ label, value, total, color }: AgingItemProps) {
const percentage = total > 0 ? (value / total) * 100 : 0;
const colorClasses = {
green: 'bg-green-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
red: 'bg-red-500',
};
const formatCurrency = (val: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(val);
};
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{label}</span>
<span className="font-medium">{formatCurrency(value)}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`${colorClasses[color]} h-2 rounded-full`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{percentage.toFixed(1)}%</p>
</div>
);
}
// ============================================================================
// PAYMENT MODAL
// ============================================================================
interface PaymentModalProps {
item: AccountsPayable;
onClose: () => void;
onSubmit: (data: RegisterAPPaymentDto) => Promise<void>;
isLoading: boolean;
}
function PaymentModal({ item, onClose, onSubmit, isLoading }: PaymentModalProps) {
const [formData, setFormData] = useState<RegisterAPPaymentDto>({
date: new Date().toISOString().split('T')[0],
amount: item.balanceAmount,
method: 'transfer',
reference: '',
notes: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const update = <K extends keyof RegisterAPPaymentDto>(field: K, value: RegisterAPPaymentDto[K]) => {
setFormData({ ...formData, [field]: value });
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
return (
<Modal
isOpen={true}
onClose={onClose}
title="Registrar Pago"
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="payment-form"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Registrando...' : 'Registrar Pago'}
</button>
</ModalFooter>
}
>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Documento: <span className="font-medium">{item.documentNumber}</span></p>
<p className="text-sm text-gray-600">Proveedor: <span className="font-medium">{item.partnerName}</span></p>
<p className="text-sm text-gray-600">Saldo pendiente: <span className="font-medium text-blue-600">{formatCurrency(item.balanceAmount)}</span></p>
</div>
<form id="payment-form" onSubmit={handleSubmit} className="space-y-4">
<FormGroup cols={2}>
<TextInput
label="Fecha de Pago"
type="date"
required
value={formData.date}
onChange={(e) => update('date', e.target.value)}
/>
<TextInput
label="Monto"
type="number"
required
min={0}
max={item.balanceAmount}
step={0.01}
value={formData.amount}
onChange={(e) => update('amount', parseFloat(e.target.value))}
/>
</FormGroup>
<FormGroup cols={2}>
<SelectField
label="Método de Pago"
required
options={[
{ value: 'transfer', label: 'Transferencia' },
{ value: 'cash', label: 'Efectivo' },
{ value: 'check', label: 'Cheque' },
{ value: 'card', label: 'Tarjeta' },
]}
value={formData.method}
onChange={(e) => update('method', e.target.value)}
/>
<TextInput
label="Referencia"
value={formData.reference || ''}
onChange={(e) => update('reference', e.target.value)}
placeholder="No. de transferencia, cheque, etc."
/>
</FormGroup>
<TextInput
label="Notas"
value={formData.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas adicionales..."
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,422 @@
/**
* FacturasPage - Listado de Facturas
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Eye,
Pencil,
Trash2,
Send,
FileText,
Download,
Stamp,
XCircle,
} from 'lucide-react';
import {
useInvoices,
useInvoiceStats,
useDeleteInvoice,
useSendInvoice,
useCancelInvoice,
useStampInvoice,
useDownloadInvoicePdf,
} from '../../../hooks/useFinance';
import type { Invoice, InvoiceStatus } from '../../../types/finance.types';
import { INVOICE_STATUS_OPTIONS } from '../../../types/finance.types';
import {
PageHeader,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
export function FacturasPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | ''>('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [sendId, setSendId] = useState<string | null>(null);
const [cancelId, setCancelId] = useState<string | null>(null);
const [stampId, setStampId] = useState<string | null>(null);
const { data, isLoading, error } = useInvoices({
status: statusFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
const { data: stats } = useInvoiceStats();
const deleteMutation = useDeleteInvoice();
const sendMutation = useSendInvoice();
const cancelMutation = useCancelInvoice();
const stampMutation = useStampInvoice();
const pdfMutation = useDownloadInvoicePdf();
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleSend = async () => {
if (sendId) {
await sendMutation.mutateAsync(sendId);
setSendId(null);
}
};
const handleCancel = async () => {
if (cancelId) {
await cancelMutation.mutateAsync({ id: cancelId, reason: 'Cancelación solicitada por usuario' });
setCancelId(null);
}
};
const handleStamp = async () => {
if (stampId) {
await stampMutation.mutateAsync(stampId);
setStampId(null);
}
};
const handleDownloadPdf = async (id: string) => {
const blob = await pdfMutation.mutateAsync(id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `factura-${id}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
// Filter by search
const items = (data?.items || []).filter(
(item) => !search ||
(item.number || item.invoiceNumber || '').toLowerCase().includes(search.toLowerCase()) ||
(item.partnerName || item.customerName || '').toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<Invoice>[] = [
{
key: 'number',
header: 'Número',
render: (item) => (
<div>
<span className="font-medium text-gray-900">{item.number || item.invoiceNumber}</span>
{(item.cfdiUuid || item.uuid) && (
<p className="text-xs text-gray-500 font-mono truncate max-w-[150px]" title={item.cfdiUuid || item.uuid}>
{(item.cfdiUuid || item.uuid || '').substring(0, 8)}...
</p>
)}
</div>
),
},
{
key: 'customer',
header: 'Cliente',
render: (item) => (
<div>
<span className="text-gray-900">{item.partnerName || item.customerName}</span>
{(item.partnerRfc || item.customerRfc) && (
<p className="text-sm text-gray-500">{item.partnerRfc || item.customerRfc}</p>
)}
</div>
),
},
{
key: 'date',
header: 'Fecha',
render: (item) => (
<div className="text-sm">
<p>Emisión: {formatDate(item.date)}</p>
{item.dueDate && (
<p className="text-gray-500">Vence: {formatDate(item.dueDate)}</p>
)}
</div>
),
},
{
key: 'amounts',
header: 'Importes',
align: 'right',
render: (item) => (
<div className="text-sm text-right">
<p className="text-gray-500">Subtotal: {formatCurrency(item.subtotal)}</p>
<p className="font-medium text-gray-900">Total: {formatCurrency(item.total)}</p>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (item) => (
<div>
<StatusBadgeFromOptions value={item.status} options={[...INVOICE_STATUS_OPTIONS]} />
{item.cfdiStatus && item.cfdiStatus !== 'none' && (
<p className="text-xs text-gray-500 mt-1">
CFDI: {item.cfdiStatus === 'stamped' ? 'Timbrada' : item.cfdiStatus === 'cancelled' ? 'Cancelada' : item.cfdiStatus}
</p>
)}
</div>
),
},
{
key: 'actions',
header: 'Acciones',
align: 'right',
render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/finanzas/facturas/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{/* Download PDF */}
<button
onClick={() => handleDownloadPdf(item.id)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Descargar PDF"
disabled={pdfMutation.isPending}
>
<Download className="w-4 h-4" />
</button>
{/* Draft actions */}
{item.status === 'draft' && (
<>
<Link
to={`/admin/finanzas/facturas/${item.id}/editar`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</Link>
<button
onClick={() => setSendId(item.id)}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Enviar"
>
<Send className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(item.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{/* Sent actions - can stamp for CFDI */}
{item.status === 'sent' && (!item.cfdiStatus || item.cfdiStatus === 'none') && (
<button
onClick={() => setStampId(item.id)}
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg"
title="Timbrar CFDI"
>
<Stamp className="w-4 h-4" />
</button>
)}
{/* Cancel action for sent/partial */}
{(item.status === 'sent' || item.status === 'partial') && (
<button
onClick={() => setCancelId(item.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Cancelar"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
),
},
];
if (isLoading) {
return <LoadingOverlay message="Cargando facturas..." />;
}
return (
<div>
<PageHeader
title="Facturas"
description="Gestión de facturación y CFDI"
actions={
<Link
to="/admin/finanzas/facturas/nueva"
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5 mr-2" />
Nueva Factura
</Link>
}
/>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<StatsCard label="Total Facturado" value={formatCurrency(stats.totalAmount || stats.totalInvoiced || 0)} color="blue" />
<StatsCard label="Pagado" value={formatCurrency(stats.paidAmount || stats.totalPaid || 0)} color="green" />
<StatsCard label="Pendiente" value={formatCurrency(stats.pendingAmount || stats.totalPending || 0)} color="yellow" />
<StatsCard label="Vencido" value={formatCurrency(stats.totalOverdue || 0)} color="red" />
<StatsCard label="Facturas" value={(stats.count || 0).toString()} color="gray" />
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por número o cliente..."
className="lg:col-span-2"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...INVOICE_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }))]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as InvoiceStatus | '')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Desde:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Hasta:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{
icon: <FileText className="w-12 h-12 text-gray-400" />,
title: 'No hay facturas',
description: 'Crea tu primera factura para comenzar.'
}}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Eliminar Factura"
message="¿Está seguro de eliminar esta factura? Esta acción no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
{/* Send Confirmation */}
<ConfirmDialog
isOpen={!!sendId}
onClose={() => setSendId(null)}
onConfirm={handleSend}
title="Enviar Factura"
message="¿Está seguro de enviar esta factura al cliente? Se notificará por correo electrónico."
confirmLabel="Enviar"
variant="info"
isLoading={sendMutation.isPending}
/>
{/* Cancel Confirmation */}
<ConfirmDialog
isOpen={!!cancelId}
onClose={() => setCancelId(null)}
onConfirm={handleCancel}
title="Cancelar Factura"
message="¿Está seguro de cancelar esta factura? Si tiene CFDI timbrado, también se cancelará ante el SAT."
confirmLabel="Cancelar Factura"
variant="danger"
isLoading={cancelMutation.isPending}
/>
{/* Stamp CFDI Confirmation */}
<ConfirmDialog
isOpen={!!stampId}
onClose={() => setStampId(null)}
onConfirm={handleStamp}
title="Timbrar CFDI"
message="¿Está seguro de timbrar esta factura? Una vez timbrada, el CFDI será válido fiscalmente."
confirmLabel="Timbrar"
variant="info"
isLoading={stampMutation.isPending}
/>
</div>
);
}
// ============================================================================
// STATS CARD COMPONENT
// ============================================================================
interface StatsCardProps {
label: string;
value: string;
color: 'blue' | 'green' | 'yellow' | 'red' | 'gray';
}
function StatsCard({ label, value, color }: StatsCardProps) {
const colorClasses = {
blue: 'border-blue-200 bg-blue-50 text-blue-700',
green: 'border-green-200 bg-green-50 text-green-700',
yellow: 'border-yellow-200 bg-yellow-50 text-yellow-700',
red: 'border-red-200 bg-red-50 text-red-700',
gray: 'border-gray-200 bg-gray-50 text-gray-700',
};
return (
<div className={`rounded-lg border-2 p-4 ${colorClasses[color]}`}>
<p className="text-sm text-gray-600">{label}</p>
<p className="text-xl font-bold">{value}</p>
</div>
);
}

View File

@ -0,0 +1,279 @@
/**
* FlujoEfectivoPage - Dashboard de Flujo de Efectivo
*/
import { useState } from 'react';
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Calendar, DollarSign, AlertTriangle } from 'lucide-react';
import {
useCashFlowSummary,
useCashFlowForecast,
} from '../../../hooks/useFinance';
import type { CashFlowPeriodType, CashFlowCategory, DailyProjection } from '../../../types/finance.types';
import {
PageHeader,
SelectField,
LoadingOverlay,
} from '../../../components/common';
export function FlujoEfectivoPage() {
const [period, setPeriod] = useState<CashFlowPeriodType>('month');
const [forecastDays, setForecastDays] = useState(30);
const { data: summary, isLoading: summaryLoading } = useCashFlowSummary(period);
const { data: forecast, isLoading: forecastLoading } = useCashFlowForecast(forecastDays);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
});
};
const periodOptions = [
{ value: 'day', label: 'Hoy' },
{ value: 'week', label: 'Esta Semana' },
{ value: 'month', label: 'Este Mes' },
{ value: 'quarter', label: 'Este Trimestre' },
{ value: 'year', label: 'Este Año' },
];
const forecastOptions = [
{ value: '7', label: '7 días' },
{ value: '15', label: '15 días' },
{ value: '30', label: '30 días' },
{ value: '60', label: '60 días' },
{ value: '90', label: '90 días' },
];
if (summaryLoading) {
return <LoadingOverlay message="Cargando flujo de efectivo..." />;
}
// Extract summary data with safe defaults
const summaryData = summary ? {
openingBalance: summary.openingBalance ?? 0,
closingBalance: summary.closingBalance ?? 0,
totalInflows: summary.totalInflows ?? 0,
totalOutflows: summary.totalOutflows ?? 0,
netChange: summary.netFlow ?? 0,
inflowCount: (summary.items?.filter((i: { type: string }) => i.type === 'inflow').length) ?? 0,
outflowCount: (summary.items?.filter((i: { type: string }) => i.type === 'outflow').length) ?? 0,
inflowsByCategory: [] as CashFlowCategory[],
outflowsByCategory: [] as CashFlowCategory[],
} : null;
// Extract forecast data with safe defaults
const forecastData = forecast ? {
expectedInflows: forecast.summary?.projectedInflows ?? 0,
expectedOutflows: forecast.summary?.projectedOutflows ?? 0,
projectedBalance: (forecast.periods?.[forecast.periods.length - 1]?.closingBalance) ?? 0,
dailyProjection: (forecast.periods?.map((p: { period: string; totalInflows: number; totalOutflows: number; netFlow: number; closingBalance: number }) => ({
date: p.period,
inflows: p.totalInflows,
outflows: p.totalOutflows,
net: p.netFlow,
balance: p.closingBalance,
}))) ?? [] as DailyProjection[],
} : null;
return (
<div>
<PageHeader
title="Flujo de Efectivo"
description="Dashboard de entradas, salidas y proyecciones de efectivo"
/>
{/* Period Selector */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-600">Período:</span>
<SelectField
options={periodOptions}
value={period}
onChange={(e) => setPeriod(e.target.value as CashFlowPeriodType)}
className="w-40"
/>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-600">Proyección:</span>
<SelectField
options={forecastOptions}
value={forecastDays.toString()}
onChange={(e) => setForecastDays(parseInt(e.target.value))}
className="w-32"
/>
</div>
</div>
</div>
{/* KPI Cards */}
{summaryData && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<KPICard
title="Saldo Inicial"
value={formatCurrency(summaryData.openingBalance)}
icon={<DollarSign className="w-6 h-6" />}
color="gray"
/>
<KPICard
title="Entradas"
value={formatCurrency(summaryData.totalInflows)}
subtitle={`${summaryData.inflowCount} movimientos`}
icon={<ArrowUpCircle className="w-6 h-6" />}
color="green"
/>
<KPICard
title="Salidas"
value={formatCurrency(summaryData.totalOutflows)}
subtitle={`${summaryData.outflowCount} movimientos`}
icon={<ArrowDownCircle className="w-6 h-6" />}
color="red"
/>
<KPICard
title="Saldo Final"
value={formatCurrency(summaryData.closingBalance)}
subtitle={summaryData.netChange >= 0 ? 'Positivo' : 'Negativo'}
icon={<TrendingUp className="w-6 h-6" />}
color={summaryData.netChange >= 0 ? 'blue' : 'orange'}
/>
</div>
)}
{/* Cash Flow Forecast */}
{!forecastLoading && forecastData && (
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-blue-600" />
Proyección de Flujo ({forecastDays} días)
</h3>
{/* Warning if projected balance is negative */}
{forecastData.projectedBalance < 0 && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<p className="text-sm text-red-800">
Alerta: Se proyecta un saldo negativo de {formatCurrency(forecastData.projectedBalance)} al final del período
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Cobros Esperados</p>
<p className="text-xl font-semibold text-green-600">{formatCurrency(forecastData.expectedInflows)}</p>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Pagos Programados</p>
<p className="text-xl font-semibold text-red-600">{formatCurrency(forecastData.expectedOutflows)}</p>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Saldo Proyectado</p>
<p className={`text-xl font-semibold ${forecastData.projectedBalance >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
{formatCurrency(forecastData.projectedBalance)}
</p>
</div>
</div>
{/* Daily Projection Table */}
{forecastData.dailyProjection.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr className="bg-gray-50">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Período</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Entradas</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Salidas</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Neto</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Saldo</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{forecastData.dailyProjection.slice(0, 10).map((day: DailyProjection, index: number) => (
<tr key={index} className={day.balance < 0 ? 'bg-red-50' : ''}>
<td className="px-4 py-3 text-sm text-gray-900">{formatDate(day.date)}</td>
<td className="px-4 py-3 text-sm text-right text-green-600">
{day.inflows > 0 ? formatCurrency(day.inflows) : '-'}
</td>
<td className="px-4 py-3 text-sm text-right text-red-600">
{day.outflows > 0 ? formatCurrency(day.outflows) : '-'}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${day.net >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(day.net)}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${day.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>
{formatCurrency(day.balance)}
</td>
</tr>
))}
</tbody>
</table>
{forecastData.dailyProjection.length > 10 && (
<p className="text-sm text-gray-500 text-center py-3">
Mostrando 10 de {forecastData.dailyProjection.length} períodos
</p>
)}
</div>
)}
</div>
)}
{/* Empty State */}
{!summaryData && !summaryLoading && (
<div className="bg-white rounded-lg shadow-sm p-8 text-center">
<DollarSign className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Sin datos de flujo</h3>
<p className="text-gray-500">No hay movimientos de efectivo para el período seleccionado.</p>
</div>
)}
</div>
);
}
// ============================================================================
// KPI CARD COMPONENT
// ============================================================================
interface KPICardProps {
title: string;
value: string;
subtitle?: string;
icon: React.ReactNode;
color: 'blue' | 'green' | 'red' | 'gray' | 'orange';
}
function KPICard({ title, value, subtitle, icon, color }: KPICardProps) {
const colorClasses = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
gray: 'bg-gray-50 text-gray-600',
orange: 'bg-orange-50 text-orange-600',
};
return (
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-semibold text-gray-900 mt-1">{value}</p>
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
</div>
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
{icon}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,369 @@
/**
* PolizasPage - Pólizas Contables (Journal Entries)
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Eye, Pencil, Trash2, CheckCircle, XCircle, FileText } from 'lucide-react';
import {
useAccountingEntries,
useDeleteAccountingEntry,
usePostAccountingEntry,
useReverseAccountingEntry,
} from '../../../hooks/useFinance';
import type { AccountingEntry, EntryStatus, EntryType } from '../../../types/finance.types';
import { ENTRY_STATUS_OPTIONS, ENTRY_TYPE_OPTIONS } from '../../../types/finance.types';
import {
PageHeader,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
export function PolizasPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<EntryStatus | ''>('');
const [typeFilter, setTypeFilter] = useState<EntryType | ''>('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [postId, setPostId] = useState<string | null>(null);
const [reverseId, setReverseId] = useState<string | null>(null);
const { data, isLoading, error } = useAccountingEntries({
status: statusFilter || undefined,
type: typeFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
const deleteMutation = useDeleteAccountingEntry();
const postMutation = usePostAccountingEntry();
const reverseMutation = useReverseAccountingEntry();
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handlePost = async () => {
if (postId) {
await postMutation.mutateAsync(postId);
setPostId(null);
}
};
const handleReverse = async () => {
if (reverseId) {
await reverseMutation.mutateAsync({ id: reverseId, reason: 'Reversión solicitada por usuario' });
setReverseId(null);
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
// Filter by search
const items = (data?.items || []).filter(
(item) => !search ||
item.entryNumber.toLowerCase().includes(search.toLowerCase()) ||
item.description.toLowerCase().includes(search.toLowerCase()) ||
item.reference?.toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<AccountingEntry>[] = [
{
key: 'number',
header: 'Número',
render: (item) => (
<div>
<span className="font-medium text-gray-900">{item.entryNumber}</span>
{item.reference && (
<p className="text-sm text-gray-500">{item.reference}</p>
)}
</div>
),
},
{
key: 'date',
header: 'Fecha',
render: (item) => (
<span className="text-gray-900">{formatDate(item.date)}</span>
),
},
{
key: 'type',
header: 'Tipo',
render: (item) => (
<StatusBadgeFromOptions value={item.type} options={[...ENTRY_TYPE_OPTIONS]} />
),
},
{
key: 'description',
header: 'Descripción',
render: (item) => (
<div className="max-w-xs">
<p className="text-gray-900 truncate">{item.description}</p>
<p className="text-sm text-gray-500">{item.lines?.length || 0} líneas</p>
</div>
),
},
{
key: 'amounts',
header: 'Importe',
align: 'right',
render: (item) => (
<div className="text-right">
<p className="font-medium text-gray-900">{formatCurrency(item.totalDebit)}</p>
{item.totalDebit !== item.totalCredit && (
<p className="text-xs text-red-600">Descuadre: {formatCurrency(Math.abs(item.totalDebit - item.totalCredit))}</p>
)}
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (item) => (
<StatusBadgeFromOptions value={item.status} options={[...ENTRY_STATUS_OPTIONS]} />
),
},
{
key: 'actions',
header: 'Acciones',
align: 'right',
render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/finanzas/polizas/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{item.status === 'draft' && (
<>
<Link
to={`/admin/finanzas/polizas/${item.id}/editar`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</Link>
<button
onClick={() => setPostId(item.id)}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Contabilizar"
>
<CheckCircle className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(item.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{item.status === 'posted' && (
<button
onClick={() => setReverseId(item.id)}
className="p-2 text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg"
title="Reversar"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
),
},
];
if (isLoading) {
return <LoadingOverlay message="Cargando pólizas contables..." />;
}
return (
<div>
<PageHeader
title="Pólizas Contables"
description="Gestión de pólizas y asientos contables"
actions={
<Link
to="/admin/finanzas/polizas/nueva"
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5 mr-2" />
Nueva Póliza
</Link>
}
/>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<StatsCard
label="Borradores"
count={data?.items.filter(i => i.status === 'draft').length || 0}
color="yellow"
/>
<StatsCard
label="Contabilizadas"
count={data?.items.filter(i => i.status === 'posted').length || 0}
color="green"
/>
<StatsCard
label="Reversadas"
count={data?.items.filter(i => i.status === 'reversed').length || 0}
color="orange"
/>
<StatsCard
label="Total"
count={data?.items.length || 0}
color="blue"
/>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por número, descripción..."
className="lg:col-span-2"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...ENTRY_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }))]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as EntryStatus | '')}
/>
<SelectField
options={[{ value: '', label: 'Todos los tipos' }, ...ENTRY_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label }))]}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as EntryType | '')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Desde:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Hasta:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{
icon: <FileText className="w-12 h-12 text-gray-400" />,
title: 'No hay pólizas',
description: 'Crea tu primera póliza contable para comenzar.'
}}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Eliminar Póliza"
message="¿Está seguro de eliminar esta póliza? Esta acción no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
{/* Post Confirmation */}
<ConfirmDialog
isOpen={!!postId}
onClose={() => setPostId(null)}
onConfirm={handlePost}
title="Contabilizar Póliza"
message="¿Está seguro de contabilizar esta póliza? Una vez contabilizada, no podrá ser editada."
confirmLabel="Contabilizar"
variant="info"
isLoading={postMutation.isPending}
/>
{/* Reverse Confirmation */}
<ConfirmDialog
isOpen={!!reverseId}
onClose={() => setReverseId(null)}
onConfirm={handleReverse}
title="Reversar Póliza"
message="¿Está seguro de reversar esta póliza? Se creará una póliza de reversión automáticamente."
confirmLabel="Reversar"
variant="warning"
isLoading={reverseMutation.isPending}
/>
</div>
);
}
// ============================================================================
// STATS CARD COMPONENT
// ============================================================================
interface StatsCardProps {
label: string;
count: number;
color: 'blue' | 'green' | 'yellow' | 'orange';
}
function StatsCard({ label, count, color }: StatsCardProps) {
const colorClasses = {
blue: 'border-blue-200 bg-blue-50',
green: 'border-green-200 bg-green-50',
yellow: 'border-yellow-200 bg-yellow-50',
orange: 'border-orange-200 bg-orange-50',
};
const textClasses = {
blue: 'text-blue-700',
green: 'text-green-700',
yellow: 'text-yellow-700',
orange: 'text-orange-700',
};
return (
<div className={`rounded-lg border-2 p-4 ${colorClasses[color]}`}>
<p className="text-sm text-gray-600">{label}</p>
<p className={`text-2xl font-bold ${textClasses[color]}`}>{count}</p>
</div>
);
}

View File

@ -0,0 +1,10 @@
/**
* Finance Pages Index
*/
export { CuentasContablesPage } from './CuentasContablesPage';
export { CuentasPorCobrarPage } from './CuentasPorCobrarPage';
export { CuentasPorPagarPage } from './CuentasPorPagarPage';
export { PolizasPage } from './PolizasPage';
export { FlujoEfectivoPage } from './FlujoEfectivoPage';
export { FacturasPage } from './FacturasPage';

View File

@ -136,16 +136,22 @@ export function LoginPage() {
</div> </div>
</div> </div>
{/* Demo Access */} {/* Demo Access - Solo visible en desarrollo con VITE_SHOW_DEMO_LOGIN=true */}
{import.meta.env.VITE_SHOW_DEMO_LOGIN === 'true' && (
<div className="text-center"> <div className="text-center">
<p className="text-sm text-gray-600 mb-2">¿Quieres ver una demo?</p> <p className="text-sm text-gray-600 mb-2">¿Quieres ver una demo?</p>
<Link <button
to="/admin/dashboard" type="button"
onClick={() => {
setEmail(import.meta.env.VITE_DEMO_EMAIL || 'admin@demo.com');
setPassword(import.meta.env.VITE_DEMO_PASSWORD || 'demo123');
}}
className="text-sm text-blue-600 hover:text-blue-700 font-medium" className="text-sm text-blue-600 hover:text-blue-700 font-medium"
> >
Acceder sin cuenta (modo demo) Usar credenciales de demo
</Link> </button>
</div> </div>
)}
</div> </div>
{/* Footer */} {/* Footer */}

View File

@ -1,223 +0,0 @@
/**
* API Client - Centralized HTTP client with interceptors
* G-004: Request/response interceptors, auth handling, error transformation
*/
import axios, {
AxiosInstance,
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
// API Configuration
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3021/api';
const API_TIMEOUT = 30000; // 30 seconds
// Token storage keys
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
// API Error interface
export interface ApiError {
message: string;
statusCode: number;
error?: string;
details?: Record<string, unknown>;
}
// Pagination response interface
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Create axios instance with base configuration
*/
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Request interceptor - Add auth token to requests
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// Log requests in development
if (import.meta.env.DEV) {
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.data || '');
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
/**
* Response interceptor - Handle responses and errors
*/
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// Log responses in development
if (import.meta.env.DEV) {
console.log(`[API] Response ${response.status}:`, response.data);
}
return response;
},
async (error: AxiosError<ApiError>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle 401 Unauthorized - Attempt token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refreshToken,
});
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (newRefreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
}
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
clearAuthTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
// Transform error for consistent handling
const apiError = transformError(error);
return Promise.reject(apiError);
}
);
/**
* Transform axios error to ApiError
*/
function transformError(error: AxiosError<ApiError>): ApiError {
if (error.response) {
// Server responded with error
return {
message: error.response.data?.message || 'Error del servidor',
statusCode: error.response.status,
error: error.response.data?.error,
details: error.response.data?.details,
};
}
if (error.request) {
// Request made but no response
return {
message: 'No se pudo conectar con el servidor',
statusCode: 0,
error: 'NETWORK_ERROR',
};
}
// Request setup error
return {
message: error.message || 'Error desconocido',
statusCode: 0,
error: 'REQUEST_ERROR',
};
}
/**
* Token management utilities
*/
export function setAuthTokens(accessToken: string, refreshToken?: string): void {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
}
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function clearAuthTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return !!getAccessToken();
}
/**
* API helper methods
*/
export const api = {
get: <T>(url: string, config?: object) =>
apiClient.get<T>(url, config).then((res) => res.data),
post: <T>(url: string, data?: unknown, config?: object) =>
apiClient.post<T>(url, data, config).then((res) => res.data),
put: <T>(url: string, data?: unknown, config?: object) =>
apiClient.put<T>(url, data, config).then((res) => res.data),
patch: <T>(url: string, data?: unknown, config?: object) =>
apiClient.patch<T>(url, data, config).then((res) => res.data),
delete: <T>(url: string, config?: object) =>
apiClient.delete<T>(url, config).then((res) => res.data),
};
/**
* Paginated request helper
*/
export async function getPaginated<T>(
url: string,
params?: {
page?: number;
limit?: number;
sort?: string;
order?: 'asc' | 'desc';
search?: string;
[key: string]: unknown;
}
): Promise<PaginatedResponse<T>> {
const response = await apiClient.get<PaginatedResponse<T>>(url, { params });
return response.data;
}
export default apiClient;

View File

@ -55,30 +55,79 @@ export interface RefreshTokenResponse {
// API CALLS // API CALLS
// ============================================================ // ============================================================
// Backend response wrapper type
interface ApiResponseWrapper<T> {
success: boolean;
data: T;
message?: string;
}
// Raw user response from backend (different from our User type)
interface BackendUserResponse {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
roles?: string[];
}
// Raw auth response from backend
interface BackendAuthResponse {
accessToken: string;
refreshToken: string;
expiresIn?: number;
user: BackendUserResponse;
tenant?: Tenant;
}
/**
* Transform backend user to frontend User type
*/
function transformUser(backendUser: BackendUserResponse, tenantId: string): User {
return {
...backendUser,
tenantId,
status: 'active',
role: backendUser.roles?.[0] || 'user',
roles: backendUser.roles,
};
}
/** /**
* Login con email y password * Login con email y password
*/ */
export async function login(credentials: LoginCredentials): Promise<AuthResponse> { export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await api.post<AuthResponse>('/auth/login', credentials); const response = await api.post<ApiResponseWrapper<BackendAuthResponse>>('/auth/login', credentials);
return response.data; const data = response.data.data;
// Transform backend response to match frontend expectations
return {
...data,
user: transformUser(data.user, data.tenant?.id || ''),
};
} }
/** /**
* Registro de nuevo usuario * Registro de nuevo usuario
*/ */
export async function register(data: RegisterData): Promise<AuthResponse> { export async function register(data: RegisterData): Promise<AuthResponse> {
const response = await api.post<AuthResponse>('/auth/register', data); const response = await api.post<ApiResponseWrapper<BackendAuthResponse>>('/auth/register', data);
return response.data; const responseData = response.data.data;
return {
...responseData,
user: transformUser(responseData.user, responseData.tenant?.id || ''),
};
} }
/** /**
* Refresh token * Refresh token
*/ */
export async function refreshToken(token: string): Promise<RefreshTokenResponse> { export async function refreshToken(token: string): Promise<RefreshTokenResponse> {
const response = await api.post<RefreshTokenResponse>('/auth/refresh', { const response = await api.post<ApiResponseWrapper<RefreshTokenResponse>>('/auth/refresh', {
refreshToken: token, refreshToken: token,
}); });
return response.data; return response.data.data;
} }
/** /**
@ -88,12 +137,30 @@ export async function logout(): Promise<void> {
await api.post('/auth/logout'); await api.post('/auth/logout');
} }
// Backend /auth/me response
interface BackendMeResponse {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
roles?: string[];
tenantId?: string;
}
/** /**
* Obtener usuario actual * Obtener usuario actual
*/ */
export async function getCurrentUser(): Promise<User> { export async function getCurrentUser(): Promise<User> {
const response = await api.get<User>('/auth/me'); const response = await api.get<ApiResponseWrapper<BackendMeResponse>>('/auth/me');
return response.data; const data = response.data.data;
return {
...data,
tenantId: data.tenantId || '',
status: 'active',
role: data.roles?.[0] || 'user',
roles: data.roles,
};
} }
/** /**

View File

@ -0,0 +1,133 @@
/**
* Accounting API - Contabilidad (Cuentas y Pólizas)
*/
import api from '../api';
import type { PaginatedResponse } from '../../types/api.types';
import type {
Account,
AccountFilters,
CreateAccountDto,
UpdateAccountDto,
AccountingEntry,
EntryFilters,
CreateEntryDto,
UpdateEntryDto,
TrialBalance,
AccountLedger,
} from '../../types/finance.types';
// ============================================================================
// ACCOUNTS API
// ============================================================================
export const accountsApi = {
list: async (filters?: AccountFilters): Promise<PaginatedResponse<Account>> => {
const response = await api.get<PaginatedResponse<Account>>('/accounting/accounts', {
params: filters,
});
return response.data;
},
tree: async (): Promise<Account[]> => {
const response = await api.get<Account[]>('/accounting/accounts/tree');
return response.data;
},
get: async (id: string): Promise<Account> => {
const response = await api.get<Account>(`/accounting/accounts/${id}`);
return response.data;
},
create: async (data: CreateAccountDto): Promise<Account> => {
const response = await api.post<Account>('/accounting/accounts', data);
return response.data;
},
update: async (id: string, data: UpdateAccountDto): Promise<Account> => {
const response = await api.put<Account>(`/accounting/accounts/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/accounting/accounts/${id}`);
},
};
// ============================================================================
// ENTRIES API (Pólizas)
// ============================================================================
export const entriesApi = {
list: async (filters?: EntryFilters): Promise<PaginatedResponse<AccountingEntry>> => {
const response = await api.get<PaginatedResponse<AccountingEntry>>('/accounting/entries', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<AccountingEntry> => {
const response = await api.get<AccountingEntry>(`/accounting/entries/${id}`);
return response.data;
},
create: async (data: CreateEntryDto): Promise<AccountingEntry> => {
const response = await api.post<AccountingEntry>('/accounting/entries', data);
return response.data;
},
update: async (id: string, data: UpdateEntryDto): Promise<AccountingEntry> => {
const response = await api.put<AccountingEntry>(`/accounting/entries/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/accounting/entries/${id}`);
},
// Workflow actions
submit: async (id: string): Promise<AccountingEntry> => {
const response = await api.post<AccountingEntry>(`/accounting/entries/${id}/submit`);
return response.data;
},
approve: async (id: string): Promise<AccountingEntry> => {
const response = await api.post<AccountingEntry>(`/accounting/entries/${id}/approve`);
return response.data;
},
post: async (id: string): Promise<AccountingEntry> => {
const response = await api.post<AccountingEntry>(`/accounting/entries/${id}/post`);
return response.data;
},
cancel: async (id: string): Promise<AccountingEntry> => {
const response = await api.post<AccountingEntry>(`/accounting/entries/${id}/cancel`);
return response.data;
},
reverse: async (id: string): Promise<AccountingEntry> => {
const response = await api.post<AccountingEntry>(`/accounting/entries/${id}/reverse`);
return response.data;
},
};
// ============================================================================
// REPORTS API
// ============================================================================
export const accountingReportsApi = {
trialBalance: async (period: string): Promise<TrialBalance> => {
const response = await api.get<TrialBalance>('/accounting/reports/trial-balance', {
params: { period },
});
return response.data;
},
accountLedger: async (accountId: string, period: string): Promise<AccountLedger> => {
const response = await api.get<AccountLedger>(`/accounting/account-ledger/${accountId}`, {
params: { period },
});
return response.data;
},
};

View File

@ -0,0 +1,66 @@
/**
* Accounts Payable API - Cuentas por Pagar
*/
import api from '../api';
import type { PaginatedResponse } from '../../types/api.types';
import type {
AccountsPayable,
APFilters,
APStats,
ScheduleAPPaymentDto,
RegisterAPPaymentDto,
} from '../../types/finance.types';
export const apApi = {
list: async (filters?: APFilters): Promise<PaginatedResponse<AccountsPayable>> => {
const response = await api.get<PaginatedResponse<AccountsPayable>>('/ap', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<AccountsPayable> => {
const response = await api.get<AccountsPayable>(`/ap/${id}`);
return response.data;
},
stats: async (): Promise<APStats> => {
const response = await api.get<APStats>('/ap/stats');
return response.data;
},
aging: async (): Promise<{
current: AccountsPayable[];
overdue30: AccountsPayable[];
overdue60: AccountsPayable[];
overdue90Plus: AccountsPayable[];
}> => {
const response = await api.get('/ap/aging');
return response.data;
},
schedule: async (id: string, data: ScheduleAPPaymentDto): Promise<AccountsPayable> => {
const response = await api.post<AccountsPayable>(`/ap/${id}/schedule`, data);
return response.data;
},
registerPayment: async (id: string, data: RegisterAPPaymentDto): Promise<AccountsPayable> => {
const response = await api.post<AccountsPayable>(`/ap/${id}/pay`, data);
return response.data;
},
getScheduled: async (): Promise<AccountsPayable[]> => {
const response = await api.get<AccountsPayable[]>('/ap/scheduled');
return response.data;
},
getCalendar: async (month: string): Promise<{
date: string;
items: AccountsPayable[];
total: number;
}[]> => {
const response = await api.get('/ap/calendar', { params: { month } });
return response.data;
},
};

View File

@ -0,0 +1,50 @@
/**
* Accounts Receivable API - Cuentas por Cobrar
*/
import api from '../api';
import type { PaginatedResponse } from '../../types/api.types';
import type {
AccountsReceivable,
ARFilters,
ARStats,
RegisterARPaymentDto,
} from '../../types/finance.types';
export const arApi = {
list: async (filters?: ARFilters): Promise<PaginatedResponse<AccountsReceivable>> => {
const response = await api.get<PaginatedResponse<AccountsReceivable>>('/ar', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<AccountsReceivable> => {
const response = await api.get<AccountsReceivable>(`/ar/${id}`);
return response.data;
},
stats: async (): Promise<ARStats> => {
const response = await api.get<ARStats>('/ar/stats');
return response.data;
},
aging: async (): Promise<{
current: AccountsReceivable[];
overdue30: AccountsReceivable[];
overdue60: AccountsReceivable[];
overdue90Plus: AccountsReceivable[];
}> => {
const response = await api.get('/ar/aging');
return response.data;
},
registerPayment: async (id: string, data: RegisterARPaymentDto): Promise<AccountsReceivable> => {
const response = await api.post<AccountsReceivable>(`/ar/${id}/payment`, data);
return response.data;
},
sendReminder: async (id: string): Promise<void> => {
await api.post(`/ar/${id}/reminder`);
},
};

View File

@ -0,0 +1,41 @@
/**
* Cash Flow API - Flujo de Efectivo
*/
import api from '../api';
import type { CashFlowPeriod, CashFlowProjection } from '../../types/finance.types';
export const cashFlowApi = {
actual: async (period: string): Promise<CashFlowPeriod> => {
const response = await api.get<CashFlowPeriod>('/cash-flow/actual', {
params: { period },
});
return response.data;
},
projected: async (months?: number): Promise<CashFlowProjection> => {
const response = await api.get<CashFlowProjection>('/cash-flow/projected', {
params: { months: months || 6 },
});
return response.data;
},
byCategory: async (period: string): Promise<{
category: string;
inflows: number;
outflows: number;
net: number;
}[]> => {
const response = await api.get('/cash-flow/by-category', {
params: { period },
});
return response.data;
},
monthly: async (year: number): Promise<CashFlowPeriod[]> => {
const response = await api.get<CashFlowPeriod[]>('/cash-flow/monthly', {
params: { year },
});
return response.data;
},
};

View File

@ -0,0 +1,9 @@
/**
* Finance Services Index
*/
export * from './accounting.api';
export * from './ar.api';
export * from './ap.api';
export * from './cash-flow.api';
export * from './invoices.api';

View File

@ -0,0 +1,95 @@
/**
* Invoices API - Facturación
*/
import api from '../api';
import type { PaginatedResponse } from '../../types/api.types';
import type {
Invoice,
InvoiceFilters,
InvoiceStats,
CreateInvoiceDto,
UpdateInvoiceDto,
} from '../../types/finance.types';
export const invoicesApi = {
list: async (filters?: InvoiceFilters): Promise<PaginatedResponse<Invoice>> => {
const response = await api.get<PaginatedResponse<Invoice>>('/invoices', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Invoice> => {
const response = await api.get<Invoice>(`/invoices/${id}`);
return response.data;
},
stats: async (): Promise<InvoiceStats> => {
const response = await api.get<InvoiceStats>('/invoices/summary');
return response.data;
},
dashboard: async (): Promise<{
stats: InvoiceStats;
recentInvoices: Invoice[];
overdueInvoices: Invoice[];
}> => {
const response = await api.get('/invoices/dashboard');
return response.data;
},
create: async (data: CreateInvoiceDto): Promise<Invoice> => {
const response = await api.post<Invoice>('/invoices', data);
return response.data;
},
update: async (id: string, data: UpdateInvoiceDto): Promise<Invoice> => {
const response = await api.patch<Invoice>(`/invoices/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/invoices/${id}`);
},
// Workflow actions
send: async (id: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`/invoices/${id}/send`);
return response.data;
},
markPaid: async (id: string, paymentData?: {
date: string;
amount: number;
method: string;
reference?: string;
}): Promise<Invoice> => {
const response = await api.post<Invoice>(`/invoices/${id}/pay`, paymentData);
return response.data;
},
cancel: async (id: string, reason?: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`/invoices/${id}/cancel`, { reason });
return response.data;
},
// PDF
getPdf: async (id: string): Promise<Blob> => {
const response = await api.get(`/invoices/${id}/pdf`, {
responseType: 'blob',
});
return response.data;
},
// CFDI (Mexico e-invoicing)
stamp: async (id: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`/invoices/${id}/stamp`);
return response.data;
},
cancelCfdi: async (id: string, reason: string): Promise<Invoice> => {
const response = await api.post<Invoice>(`/invoices/${id}/cancel-cfdi`, { reason });
return response.data;
},
};

View File

@ -0,0 +1,556 @@
/**
* Mock Data for Construction Module
* Used as fallback when API calls fail
*/
import { Fraccionamiento } from './construccion/fraccionamientos.api';
import { Etapa } from './construccion/etapas.api';
import { Manzana } from './construccion/manzanas.api';
import { Lote } from './construccion/lotes.api';
import { Prototipo } from './construccion/prototipos.api';
const TENANT_ID = '00000000-0000-0000-0003-000000000001';
const PROJECT_ID = 'proj-001';
// =============================================================================
// Fraccionamientos Mock
// =============================================================================
export const mockFraccionamientos: Fraccionamiento[] = [
{
id: 'frac-001',
tenantId: TENANT_ID,
proyectoId: PROJECT_ID,
codigo: 'FA-001',
nombre: 'Fraccionamiento Los Álamos',
descripcion: 'Desarrollo habitacional de 250 viviendas en zona norte',
direccion: 'Av. Principal #100, Col. Norte',
fechaInicio: '2025-06-01',
fechaFinEstimada: '2027-12-31',
estado: 'activo',
createdAt: '2025-05-15T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
},
{
id: 'frac-002',
tenantId: TENANT_ID,
proyectoId: PROJECT_ID,
codigo: 'FA-002',
nombre: 'Residencial Las Palmas',
descripcion: 'Fraccionamiento residencial de lujo con 80 lotes',
direccion: 'Blvd. Las Palmas #500, Col. Centro',
fechaInicio: '2025-03-01',
fechaFinEstimada: '2026-09-30',
estado: 'activo',
createdAt: '2025-02-01T10:00:00Z',
updatedAt: '2026-01-10T10:00:00Z',
},
{
id: 'frac-003',
tenantId: TENANT_ID,
proyectoId: PROJECT_ID,
codigo: 'FA-003',
nombre: 'Plaza Comercial Norte',
descripcion: 'Desarrollo comercial con 45 locales',
direccion: 'Carretera Norte Km 5',
fechaInicio: '2025-09-01',
fechaFinEstimada: '2026-06-30',
estado: 'pausado',
createdAt: '2025-08-01T10:00:00Z',
updatedAt: '2025-12-15T10:00:00Z',
},
{
id: 'frac-004',
tenantId: TENANT_ID,
proyectoId: PROJECT_ID,
codigo: 'FA-004',
nombre: 'Condominios Vista Hermosa',
descripcion: 'Torre de 12 pisos con 48 departamentos',
direccion: 'Av. Vista Hermosa #200',
fechaInicio: '2024-01-15',
fechaFinEstimada: '2025-06-30',
estado: 'completado',
createdAt: '2023-12-01T10:00:00Z',
updatedAt: '2025-06-30T10:00:00Z',
},
];
// =============================================================================
// Etapas Mock
// =============================================================================
export const mockEtapas: Etapa[] = [
{
id: 'etapa-001',
tenantId: TENANT_ID,
fraccionamientoId: 'frac-001',
code: 'E1',
name: 'Etapa 1 - Norte',
description: 'Primera etapa del fraccionamiento, zona norte',
sequence: 1,
totalLots: 80,
status: 'completed',
startDate: '2025-06-01',
expectedEndDate: '2026-03-31',
actualEndDate: '2026-02-28',
createdAt: '2025-05-15T10:00:00Z',
updatedAt: '2026-02-28T10:00:00Z',
fraccionamiento: {
id: 'frac-001',
nombre: 'Fraccionamiento Los Álamos',
codigo: 'FA-001',
},
},
{
id: 'etapa-002',
tenantId: TENANT_ID,
fraccionamientoId: 'frac-001',
code: 'E2',
name: 'Etapa 2 - Centro',
description: 'Segunda etapa, zona central',
sequence: 2,
totalLots: 100,
status: 'in_progress',
startDate: '2026-01-01',
expectedEndDate: '2026-12-31',
createdAt: '2025-12-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
fraccionamiento: {
id: 'frac-001',
nombre: 'Fraccionamiento Los Álamos',
codigo: 'FA-001',
},
},
{
id: 'etapa-003',
tenantId: TENANT_ID,
fraccionamientoId: 'frac-001',
code: 'E3',
name: 'Etapa 3 - Sur',
description: 'Tercera etapa, zona sur',
sequence: 3,
totalLots: 70,
status: 'planned',
startDate: '2027-01-01',
expectedEndDate: '2027-12-31',
createdAt: '2025-12-15T10:00:00Z',
updatedAt: '2025-12-15T10:00:00Z',
fraccionamiento: {
id: 'frac-001',
nombre: 'Fraccionamiento Los Álamos',
codigo: 'FA-001',
},
},
{
id: 'etapa-004',
tenantId: TENANT_ID,
fraccionamientoId: 'frac-002',
code: 'E1',
name: 'Etapa Única',
description: 'Etapa única del fraccionamiento residencial',
sequence: 1,
totalLots: 80,
status: 'in_progress',
startDate: '2025-03-01',
expectedEndDate: '2026-09-30',
createdAt: '2025-02-01T10:00:00Z',
updatedAt: '2026-01-10T10:00:00Z',
fraccionamiento: {
id: 'frac-002',
nombre: 'Residencial Las Palmas',
codigo: 'FA-002',
},
},
];
// =============================================================================
// Manzanas Mock
// =============================================================================
export const mockManzanas: Manzana[] = [
{
id: 'manzana-001',
tenantId: TENANT_ID,
etapaId: 'etapa-001',
code: 'M1',
name: 'Manzana 1',
description: 'Manzana esquinera con vista al parque',
totalLots: 20,
sequence: 1,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
etapa: {
id: 'etapa-001',
code: 'E1',
name: 'Etapa 1 - Norte',
fraccionamientoId: 'frac-001',
},
},
{
id: 'manzana-002',
tenantId: TENANT_ID,
etapaId: 'etapa-001',
code: 'M2',
name: 'Manzana 2',
description: 'Manzana central',
totalLots: 25,
sequence: 2,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
etapa: {
id: 'etapa-001',
code: 'E1',
name: 'Etapa 1 - Norte',
fraccionamientoId: 'frac-001',
},
},
{
id: 'manzana-003',
tenantId: TENANT_ID,
etapaId: 'etapa-001',
code: 'M3',
name: 'Manzana 3',
description: 'Manzana junto a área verde',
totalLots: 18,
sequence: 3,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
etapa: {
id: 'etapa-001',
code: 'E1',
name: 'Etapa 1 - Norte',
fraccionamientoId: 'frac-001',
},
},
{
id: 'manzana-004',
tenantId: TENANT_ID,
etapaId: 'etapa-002',
code: 'M4',
name: 'Manzana 4',
description: 'Primera manzana de etapa 2',
totalLots: 22,
sequence: 1,
createdAt: '2026-01-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
etapa: {
id: 'etapa-002',
code: 'E2',
name: 'Etapa 2 - Centro',
fraccionamientoId: 'frac-001',
},
},
];
// =============================================================================
// Lotes Mock
// =============================================================================
export const mockLotes: Lote[] = [
{
id: 'lote-001',
tenantId: TENANT_ID,
manzanaId: 'manzana-001',
prototipoId: 'proto-001',
code: 'L01',
officialNumber: '001',
areaM2: 120,
frontM: 8,
depthM: 15,
status: 'sold',
basePrice: 450000,
finalPrice: 475000,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2026-01-10T10:00:00Z',
manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' },
prototipo: { id: 'proto-001', code: 'CASA-A', name: 'Casa Álamo', type: 'house' },
},
{
id: 'lote-002',
tenantId: TENANT_ID,
manzanaId: 'manzana-001',
prototipoId: 'proto-002',
code: 'L02',
officialNumber: '002',
areaM2: 150,
frontM: 10,
depthM: 15,
status: 'in_construction',
basePrice: 520000,
finalPrice: 550000,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' },
prototipo: { id: 'proto-002', code: 'CASA-B', name: 'Casa Roble', type: 'house' },
},
{
id: 'lote-003',
tenantId: TENANT_ID,
manzanaId: 'manzana-001',
code: 'L03',
officialNumber: '003',
areaM2: 130,
frontM: 8.5,
depthM: 15.3,
status: 'reserved',
basePrice: 480000,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2026-01-12T10:00:00Z',
manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' },
},
{
id: 'lote-004',
tenantId: TENANT_ID,
manzanaId: 'manzana-001',
code: 'L04',
officialNumber: '004',
areaM2: 125,
frontM: 8.3,
depthM: 15.1,
status: 'available',
basePrice: 465000,
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2025-06-01T10:00:00Z',
manzana: { id: 'manzana-001', code: 'M1', name: 'Manzana 1', etapaId: 'etapa-001' },
},
{
id: 'lote-005',
tenantId: TENANT_ID,
manzanaId: 'manzana-002',
prototipoId: 'proto-001',
code: 'L05',
officialNumber: '005',
areaM2: 140,
frontM: 9,
depthM: 15.5,
status: 'sold',
basePrice: 500000,
finalPrice: 520000,
createdAt: '2025-06-15T10:00:00Z',
updatedAt: '2026-01-05T10:00:00Z',
manzana: { id: 'manzana-002', code: 'M2', name: 'Manzana 2', etapaId: 'etapa-001' },
prototipo: { id: 'proto-001', code: 'CASA-A', name: 'Casa Álamo', type: 'house' },
},
];
// =============================================================================
// Prototipos Mock
// =============================================================================
export const mockPrototipos: Prototipo[] = [
{
id: 'proto-001',
tenantId: TENANT_ID,
code: 'CASA-A',
name: 'Casa Álamo',
description: 'Casa de un piso, diseño moderno con jardín frontal',
type: 'house',
constructionAreaM2: 85,
landAreaM2: 120,
bedrooms: 2,
bathrooms: 1,
parkingSpaces: 1,
floors: 1,
basePrice: 950000,
features: ['Jardín frontal', 'Cocina integral', 'Closets'],
isActive: true,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-12-01T10:00:00Z',
},
{
id: 'proto-002',
tenantId: TENANT_ID,
code: 'CASA-B',
name: 'Casa Roble',
description: 'Casa de dos pisos con amplio jardín trasero',
type: 'house',
constructionAreaM2: 120,
landAreaM2: 150,
bedrooms: 3,
bathrooms: 2,
parkingSpaces: 2,
floors: 2,
basePrice: 1450000,
features: ['Jardín trasero', 'Cocina integral', 'Cuarto de servicio', 'Roof garden'],
isActive: true,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-12-01T10:00:00Z',
},
{
id: 'proto-003',
tenantId: TENANT_ID,
code: 'CASA-C',
name: 'Casa Cedro Premium',
description: 'Casa residencial de lujo con acabados premium',
type: 'house',
constructionAreaM2: 180,
landAreaM2: 200,
bedrooms: 4,
bathrooms: 3,
parkingSpaces: 2,
floors: 2,
basePrice: 2200000,
features: ['Piscina', 'Jardín grande', 'Cuarto de TV', 'Vestidor master', 'Doble altura'],
isActive: true,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-12-01T10:00:00Z',
},
{
id: 'proto-004',
tenantId: TENANT_ID,
code: 'DEPTO-A',
name: 'Departamento Vista',
description: 'Departamento con vista panorámica',
type: 'apartment',
constructionAreaM2: 75,
landAreaM2: 0,
bedrooms: 2,
bathrooms: 1,
parkingSpaces: 1,
floors: 1,
basePrice: 850000,
features: ['Balcón', 'Vista panorámica', 'Área de lavado'],
isActive: true,
createdAt: '2025-03-01T10:00:00Z',
updatedAt: '2025-12-01T10:00:00Z',
},
];
// =============================================================================
// Filter Helpers
// =============================================================================
export function filterFraccionamientos(
items: Fraccionamiento[],
filters?: { search?: string; estado?: string; proyectoId?: string }
): Fraccionamiento[] {
let result = [...items];
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(f) =>
f.nombre.toLowerCase().includes(searchLower) ||
f.codigo.toLowerCase().includes(searchLower) ||
f.descripcion?.toLowerCase().includes(searchLower)
);
}
if (filters?.estado) {
result = result.filter((f) => f.estado === filters.estado);
}
if (filters?.proyectoId) {
result = result.filter((f) => f.proyectoId === filters.proyectoId);
}
return result;
}
export function filterEtapas(
items: Etapa[],
filters?: { search?: string; status?: string; fraccionamientoId?: string }
): Etapa[] {
let result = [...items];
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(e) =>
e.name.toLowerCase().includes(searchLower) ||
e.code.toLowerCase().includes(searchLower) ||
e.description?.toLowerCase().includes(searchLower)
);
}
if (filters?.status) {
result = result.filter((e) => e.status === filters.status);
}
if (filters?.fraccionamientoId) {
result = result.filter((e) => e.fraccionamientoId === filters.fraccionamientoId);
}
return result;
}
export function filterManzanas(
items: Manzana[],
filters?: { search?: string; etapaId?: string }
): Manzana[] {
let result = [...items];
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(m) =>
m.name.toLowerCase().includes(searchLower) ||
m.code.toLowerCase().includes(searchLower) ||
m.description?.toLowerCase().includes(searchLower)
);
}
if (filters?.etapaId) {
result = result.filter((m) => m.etapaId === filters.etapaId);
}
return result;
}
export function filterLotes(
items: Lote[],
filters?: { search?: string; status?: string; manzanaId?: string; prototipoId?: string }
): Lote[] {
let result = [...items];
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(l) =>
l.code.toLowerCase().includes(searchLower) ||
l.officialNumber?.toLowerCase().includes(searchLower) ||
l.manzana?.name.toLowerCase().includes(searchLower)
);
}
if (filters?.status) {
result = result.filter((l) => l.status === filters.status);
}
if (filters?.manzanaId) {
result = result.filter((l) => l.manzanaId === filters.manzanaId);
}
if (filters?.prototipoId) {
result = result.filter((l) => l.prototipoId === filters.prototipoId);
}
return result;
}
export function filterPrototipos(
items: Prototipo[],
filters?: { search?: string; type?: string; isActive?: boolean }
): Prototipo[] {
let result = [...items];
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(searchLower) ||
p.code.toLowerCase().includes(searchLower) ||
p.description?.toLowerCase().includes(searchLower)
);
}
if (filters?.type) {
result = result.filter((p) => p.type === filters.type);
}
if (filters?.isActive !== undefined) {
result = result.filter((p) => p.isActive === filters.isActive);
}
return result;
}

View File

@ -0,0 +1,678 @@
/**
* Mock Data for Additional Modules
* Used as fallback when API calls fail
* Uses actual types from API files
*/
import { Concepto, Presupuesto, PresupuestoEstado } from './presupuestos/presupuestos.api';
import { Estimacion } from './presupuestos/estimaciones.api';
// =============================================================================
// PRESUPUESTOS MOCK DATA
// =============================================================================
export const mockConceptos: Concepto[] = [
{
id: 'concepto-001',
tenantId: '00000000-0000-0000-0003-000000000001',
codigo: '01',
descripcion: 'PRELIMINARES',
unidad: 'CAP',
tipo: 'capitulo',
nivel: 1,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-01-01T10:00:00Z',
},
{
id: 'concepto-002',
tenantId: '00000000-0000-0000-0003-000000000001',
codigo: '01.01',
descripcion: 'Limpieza y trazo',
unidad: 'M2',
tipo: 'concepto',
precioUnitario: 45.50,
parentId: 'concepto-001',
nivel: 2,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-01-01T10:00:00Z',
},
{
id: 'concepto-003',
tenantId: '00000000-0000-0000-0003-000000000001',
codigo: '01.02',
descripcion: 'Demolición de estructuras existentes',
unidad: 'M3',
tipo: 'concepto',
precioUnitario: 285.00,
parentId: 'concepto-001',
nivel: 2,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-01-01T10:00:00Z',
},
{
id: 'concepto-004',
tenantId: '00000000-0000-0000-0003-000000000001',
codigo: '02',
descripcion: 'CIMENTACIÓN',
unidad: 'CAP',
tipo: 'capitulo',
nivel: 1,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-01-01T10:00:00Z',
},
{
id: 'concepto-005',
tenantId: '00000000-0000-0000-0003-000000000001',
codigo: '02.01',
descripcion: 'Excavación a cielo abierto',
unidad: 'M3',
tipo: 'concepto',
precioUnitario: 125.00,
parentId: 'concepto-004',
nivel: 2,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-01-01T10:00:00Z',
},
{
id: 'concepto-006',
tenantId: '00000000-0000-0000-0003-000000000001',
codigo: '02.02',
descripcion: 'Zapata aislada de concreto f\'c=250 kg/cm2',
unidad: 'M3',
tipo: 'concepto',
precioUnitario: 3450.00,
parentId: 'concepto-004',
nivel: 2,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2025-01-01T10:00:00Z',
},
];
export const mockPresupuestos: Presupuesto[] = [
{
id: 'presupuesto-001',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
codigo: 'PRES-2026-001',
nombre: 'Presupuesto Fraccionamiento Los Álamos - Etapa 1',
version: 1,
montoTotal: 15000000,
estado: 'aprobado' as PresupuestoEstado,
fechaCreacion: '2025-05-01',
fechaAprobacion: '2025-05-15',
createdAt: '2025-05-01T10:00:00Z',
updatedAt: '2025-05-15T10:00:00Z',
},
{
id: 'presupuesto-002',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-002',
codigo: 'PRES-2026-002',
nombre: 'Presupuesto Torre Corporativa Centro',
version: 2,
montoTotal: 8500000,
estado: 'aprobado' as PresupuestoEstado,
fechaCreacion: '2025-06-01',
fechaAprobacion: '2025-06-10',
createdAt: '2025-06-01T10:00:00Z',
updatedAt: '2025-06-10T10:00:00Z',
},
{
id: 'presupuesto-003',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-003',
codigo: 'PRES-2026-003',
nombre: 'Presupuesto Plaza Comercial Norte',
version: 1,
montoTotal: 12000000,
estado: 'borrador' as PresupuestoEstado,
fechaCreacion: '2025-08-01',
createdAt: '2025-08-01T10:00:00Z',
updatedAt: '2025-08-01T10:00:00Z',
},
];
export const mockEstimaciones: Estimacion[] = [
{
id: 'estimacion-001',
tenantId: '00000000-0000-0000-0003-000000000001',
presupuestoId: 'presupuesto-001',
proyectoId: 'proj-001',
numero: 1,
periodo: '2025-06',
fechaInicio: '2025-06-01',
fechaFin: '2025-06-30',
montoEstimado: 1500000,
deductivas: 25000,
montoAprobado: 1475000,
estado: 'cobrado',
createdAt: '2025-07-01T10:00:00Z',
updatedAt: '2025-07-15T10:00:00Z',
},
{
id: 'estimacion-002',
tenantId: '00000000-0000-0000-0003-000000000001',
presupuestoId: 'presupuesto-001',
proyectoId: 'proj-001',
numero: 2,
periodo: '2025-07',
fechaInicio: '2025-07-01',
fechaFin: '2025-07-31',
montoEstimado: 1800000,
deductivas: 35000,
montoAprobado: 1765000,
estado: 'facturado',
createdAt: '2025-08-01T10:00:00Z',
updatedAt: '2025-08-10T10:00:00Z',
},
{
id: 'estimacion-003',
tenantId: '00000000-0000-0000-0003-000000000001',
presupuestoId: 'presupuesto-001',
proyectoId: 'proj-001',
numero: 3,
periodo: '2025-08',
fechaInicio: '2025-08-01',
fechaFin: '2025-08-31',
montoEstimado: 2100000,
deductivas: 42000,
montoAprobado: 2058000,
estado: 'aprobado',
createdAt: '2025-09-01T10:00:00Z',
updatedAt: '2025-09-05T10:00:00Z',
},
];
// =============================================================================
// HSE MOCK DATA - Simple interfaces to avoid circular deps
// =============================================================================
export interface MockIncidente {
id: string;
tenantId: string;
proyectoId: string;
codigo: string;
titulo: string;
descripcion: string;
tipo: string;
gravedad: string;
estado: string;
fechaOcurrencia: string;
ubicacion: string;
personasAfectadas: number;
createdAt: string;
updatedAt: string;
}
export interface MockCapacitacion {
id: string;
tenantId: string;
titulo: string;
descripcion: string;
tipo: string;
duracionHoras: number;
instructor: string;
fechaProgramada: string;
estado: string;
asistentes: number;
createdAt: string;
updatedAt: string;
}
export interface MockInspeccion {
id: string;
tenantId: string;
proyectoId: string;
codigo: string;
titulo: string;
tipo: string;
fechaProgramada: string;
fechaRealizacion?: string;
inspector: string;
estado: string;
hallazgos: number;
hallazgosCriticos: number;
createdAt: string;
updatedAt: string;
}
export const mockIncidentes: MockIncidente[] = [
{
id: 'incidente-001',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
codigo: 'INC-2026-001',
titulo: 'Caída de altura menor',
descripcion: 'Trabajador resbaló en andamio mojado, sin lesiones mayores',
tipo: 'incidente',
gravedad: 'leve',
estado: 'cerrado',
fechaOcurrencia: '2026-01-15',
ubicacion: 'Manzana 2, Lote 5',
personasAfectadas: 1,
createdAt: '2026-01-15T10:00:00Z',
updatedAt: '2026-01-20T10:00:00Z',
},
{
id: 'incidente-002',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-002',
codigo: 'INC-2026-002',
titulo: 'Exposición a material peligroso',
descripcion: 'Contacto con cemento sin guantes apropiados',
tipo: 'casi_accidente',
gravedad: 'moderado',
estado: 'investigacion',
fechaOcurrencia: '2026-01-28',
ubicacion: 'Nivel 3, Zona A',
personasAfectadas: 2,
createdAt: '2026-01-28T10:00:00Z',
updatedAt: '2026-01-30T10:00:00Z',
},
{
id: 'incidente-003',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
codigo: 'INC-2026-003',
titulo: 'Cable eléctrico expuesto',
descripcion: 'Se detectó cable sin protección en área de trabajo',
tipo: 'condicion_insegura',
gravedad: 'grave',
estado: 'reportado',
fechaOcurrencia: '2026-02-01',
ubicacion: 'Bodega principal',
personasAfectadas: 0,
createdAt: '2026-02-01T10:00:00Z',
updatedAt: '2026-02-01T10:00:00Z',
},
];
export const mockCapacitaciones: MockCapacitacion[] = [
{
id: 'capacitacion-001',
tenantId: '00000000-0000-0000-0003-000000000001',
titulo: 'Inducción de seguridad en obra',
descripcion: 'Capacitación básica para personal nuevo',
tipo: 'induccion',
duracionHoras: 4,
instructor: 'Ing. Roberto Sánchez',
fechaProgramada: '2026-02-05',
estado: 'completada',
asistentes: 25,
createdAt: '2026-01-20T10:00:00Z',
updatedAt: '2026-02-05T10:00:00Z',
},
{
id: 'capacitacion-002',
tenantId: '00000000-0000-0000-0003-000000000001',
titulo: 'Trabajo en alturas',
descripcion: 'Capacitación específica para trabajo en andamios y estructuras',
tipo: 'especifica',
duracionHoras: 8,
instructor: 'Ing. María González',
fechaProgramada: '2026-02-10',
estado: 'programada',
asistentes: 15,
createdAt: '2026-01-25T10:00:00Z',
updatedAt: '2026-01-25T10:00:00Z',
},
{
id: 'capacitacion-003',
tenantId: '00000000-0000-0000-0003-000000000001',
titulo: 'Primeros auxilios',
descripcion: 'Reciclaje anual de conocimientos en primeros auxilios',
tipo: 'reciclaje',
duracionHoras: 6,
instructor: 'Dr. Carlos López',
fechaProgramada: '2026-02-15',
estado: 'programada',
asistentes: 30,
createdAt: '2026-01-28T10:00:00Z',
updatedAt: '2026-01-28T10:00:00Z',
},
];
export const mockInspecciones: MockInspeccion[] = [
{
id: 'inspeccion-001',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
codigo: 'INSP-2026-001',
titulo: 'Inspección semanal de obra',
tipo: 'rutinaria',
fechaProgramada: '2026-02-03',
fechaRealizacion: '2026-02-03',
inspector: 'Ing. Pedro Ramírez',
estado: 'completada',
hallazgos: 5,
hallazgosCriticos: 1,
createdAt: '2026-01-27T10:00:00Z',
updatedAt: '2026-02-03T10:00:00Z',
},
{
id: 'inspeccion-002',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-002',
codigo: 'INSP-2026-002',
titulo: 'Auditoría de EPP',
tipo: 'auditoria',
fechaProgramada: '2026-02-08',
inspector: 'Ing. Ana Torres',
estado: 'programada',
hallazgos: 0,
hallazgosCriticos: 0,
createdAt: '2026-02-01T10:00:00Z',
updatedAt: '2026-02-01T10:00:00Z',
},
];
// =============================================================================
// BIDDING MOCK DATA - Using actual API types
// =============================================================================
import { Opportunity, Tender, Vendor } from './bidding';
export const mockOportunidades: Opportunity[] = [
{
id: 'oportunidad-001',
tenantId: '00000000-0000-0000-0003-000000000001',
title: 'Construcción de Hospital Regional',
clientName: 'Gobierno del Estado',
description: 'Licitación para construcción de hospital de 200 camas',
estimatedValue: 150000000,
expectedCloseDate: '2026-03-15',
status: 'qualified',
probability: 45,
createdAt: '2026-01-15T10:00:00Z',
updatedAt: '2026-01-30T10:00:00Z',
},
{
id: 'oportunidad-002',
tenantId: '00000000-0000-0000-0003-000000000001',
title: 'Ampliación Centro Comercial',
clientName: 'Grupo Comercial del Norte',
description: 'Ampliación de 15,000 m2 adicionales',
estimatedValue: 45000000,
expectedCloseDate: '2026-02-28',
status: 'proposal',
probability: 70,
createdAt: '2026-01-10T10:00:00Z',
updatedAt: '2026-02-01T10:00:00Z',
},
{
id: 'oportunidad-003',
tenantId: '00000000-0000-0000-0003-000000000001',
title: 'Pavimentación Zona Industrial',
clientName: 'Municipio',
description: 'Pavimentación de 8 km de vialidades',
estimatedValue: 22000000,
expectedCloseDate: '2026-04-01',
status: 'lead',
probability: 30,
createdAt: '2026-02-01T10:00:00Z',
updatedAt: '2026-02-01T10:00:00Z',
},
];
export const mockConcursos: Tender[] = [
{
id: 'concurso-001',
tenantId: '00000000-0000-0000-0003-000000000001',
referenceNumber: 'LIC-2026-001',
title: 'Construcción de Puente Vehicular',
issuingEntity: 'SCT',
estimatedBudget: 85000000,
publicationDate: '2026-01-15',
submissionDeadline: '2026-02-15',
status: 'evaluation',
createdAt: '2026-01-10T10:00:00Z',
updatedAt: '2026-01-20T10:00:00Z',
},
{
id: 'concurso-002',
tenantId: '00000000-0000-0000-0003-000000000001',
referenceNumber: 'LIC-2026-002',
title: 'Remodelación de Oficinas Gubernamentales',
issuingEntity: 'Gobierno Federal',
estimatedBudget: 12000000,
publicationDate: '2026-02-01',
submissionDeadline: '2026-02-20',
status: 'published',
createdAt: '2026-01-25T10:00:00Z',
updatedAt: '2026-01-25T10:00:00Z',
},
];
export const mockProveedores: Vendor[] = [
{
id: 'proveedor-001',
tenantId: '00000000-0000-0000-0003-000000000001',
businessName: 'Aceros del Norte S.A. de C.V.',
rfc: 'ANO123456789',
contactName: 'Juan Pérez',
phone: '555-123-4567',
email: 'ventas@acerosnorte.com',
isActive: true,
createdAt: '2025-01-01T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
},
{
id: 'proveedor-002',
tenantId: '00000000-0000-0000-0003-000000000001',
businessName: 'Maquinaria Pesada Central',
rfc: 'MPC987654321',
contactName: 'María García',
phone: '555-987-6543',
email: 'renta@maqpesada.com',
isActive: true,
createdAt: '2025-02-01T10:00:00Z',
updatedAt: '2026-01-10T10:00:00Z',
},
{
id: 'proveedor-003',
tenantId: '00000000-0000-0000-0003-000000000001',
businessName: 'Instalaciones Eléctricas Profesionales',
rfc: 'IEP456789012',
contactName: 'Roberto Sánchez',
phone: '555-456-7890',
email: 'cotiza@iepro.com',
isActive: true,
createdAt: '2025-03-01T10:00:00Z',
updatedAt: '2026-01-05T10:00:00Z',
},
];
// =============================================================================
// PROGRESS (AVANCES) MOCK DATA
// =============================================================================
export interface MockAvance {
id: string;
tenantId: string;
proyectoId: string;
conceptoId: string;
periodo: string;
cantidadProgramada: number;
cantidadReal: number;
porcentajeAvance: number;
observaciones?: string;
createdAt: string;
updatedAt: string;
}
export interface MockBitacora {
id: string;
tenantId: string;
proyectoId: string;
fecha: string;
clima: string;
personalObra: number;
personalContratista: number;
actividadesRealizadas: string;
observaciones?: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export const mockAvances: MockAvance[] = [
{
id: 'avance-001',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
conceptoId: 'concepto-002',
periodo: '2026-01',
cantidadProgramada: 1000,
cantidadReal: 950,
porcentajeAvance: 95,
observaciones: 'Ligero retraso por lluvia',
createdAt: '2026-01-31T10:00:00Z',
updatedAt: '2026-01-31T10:00:00Z',
},
{
id: 'avance-002',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
conceptoId: 'concepto-005',
periodo: '2026-01',
cantidadProgramada: 500,
cantidadReal: 520,
porcentajeAvance: 104,
observaciones: 'Avance adelantado',
createdAt: '2026-01-31T10:00:00Z',
updatedAt: '2026-01-31T10:00:00Z',
},
];
export const mockBitacoras: MockBitacora[] = [
{
id: 'bitacora-001',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
fecha: '2026-02-03',
clima: 'soleado',
personalObra: 45,
personalContratista: 12,
actividadesRealizadas: 'Continuación de cimentación en manzana 3. Colado de zapatas Z-15 a Z-20.',
observaciones: 'Se recibió entrega de acero para siguientes etapas.',
createdBy: 'Ing. Carlos Mendoza',
createdAt: '2026-02-03T18:00:00Z',
updatedAt: '2026-02-03T18:00:00Z',
},
{
id: 'bitacora-002',
tenantId: '00000000-0000-0000-0003-000000000001',
proyectoId: 'proj-001',
fecha: '2026-02-02',
clima: 'nublado',
personalObra: 42,
personalContratista: 15,
actividadesRealizadas: 'Armado de acero en zapatas. Preparación de cimbra para trabes.',
observaciones: 'Se realizó inspección de calidad sin observaciones.',
createdBy: 'Ing. Carlos Mendoza',
createdAt: '2026-02-02T18:00:00Z',
updatedAt: '2026-02-02T18:00:00Z',
},
];
// =============================================================================
// FINANCE MOCK DATA
// =============================================================================
export interface MockCuentaPorCobrar {
id: string;
tenantId: string;
clienteId: string;
clienteNombre: string;
facturaNumero: string;
concepto: string;
monto: number;
saldo: number;
fechaEmision: string;
fechaVencimiento: string;
estado: string;
createdAt: string;
updatedAt: string;
}
export interface MockCuentaPorPagar {
id: string;
tenantId: string;
proveedorId: string;
proveedorNombre: string;
facturaNumero: string;
concepto: string;
monto: number;
saldo: number;
fechaEmision: string;
fechaVencimiento: string;
estado: string;
createdAt: string;
updatedAt: string;
}
export const mockCuentasPorCobrar: MockCuentaPorCobrar[] = [
{
id: 'cxc-001',
tenantId: '00000000-0000-0000-0003-000000000001',
clienteId: 'cliente-001',
clienteNombre: 'Desarrollos Inmobiliarios del Norte',
facturaNumero: 'FAC-2026-0125',
concepto: 'Estimación #3 Fraccionamiento Los Álamos',
monto: 2058000,
saldo: 2058000,
fechaEmision: '2026-01-15',
fechaVencimiento: '2026-02-15',
estado: 'pendiente',
createdAt: '2026-01-15T10:00:00Z',
updatedAt: '2026-01-15T10:00:00Z',
},
{
id: 'cxc-002',
tenantId: '00000000-0000-0000-0003-000000000001',
clienteId: 'cliente-002',
clienteNombre: 'Grupo Constructor Centro',
facturaNumero: 'FAC-2026-0098',
concepto: 'Anticipo obra Torre Corporativa',
monto: 1500000,
saldo: 500000,
fechaEmision: '2026-01-01',
fechaVencimiento: '2026-01-31',
estado: 'parcial',
createdAt: '2026-01-01T10:00:00Z',
updatedAt: '2026-01-20T10:00:00Z',
},
];
export const mockCuentasPorPagar: MockCuentaPorPagar[] = [
{
id: 'cxp-001',
tenantId: '00000000-0000-0000-0003-000000000001',
proveedorId: 'proveedor-001',
proveedorNombre: 'Aceros del Norte S.A. de C.V.',
facturaNumero: 'A-45678',
concepto: 'Acero de refuerzo para cimentación',
monto: 450000,
saldo: 450000,
fechaEmision: '2026-01-20',
fechaVencimiento: '2026-02-20',
estado: 'pendiente',
createdAt: '2026-01-20T10:00:00Z',
updatedAt: '2026-01-20T10:00:00Z',
},
{
id: 'cxp-002',
tenantId: '00000000-0000-0000-0003-000000000001',
proveedorId: 'proveedor-002',
proveedorNombre: 'Maquinaria Pesada Central',
facturaNumero: 'MPC-2026-089',
concepto: 'Renta de retroexcavadora enero',
monto: 85000,
saldo: 0,
fechaEmision: '2026-01-05',
fechaVencimiento: '2026-01-20',
estado: 'pagada',
createdAt: '2026-01-05T10:00:00Z',
updatedAt: '2026-01-18T10:00:00Z',
},
];

View File

@ -0,0 +1,240 @@
/**
* Mock Data for Development/Demo
* Used as fallback when API calls fail
*/
import {
DashboardStats,
ProjectSummary,
EarnedValueMetrics,
Alert,
SCurveDataPoint,
} from './reports';
// =============================================================================
// Dashboard Stats Mock
// =============================================================================
export const mockDashboardStats: DashboardStats = {
totalProyectos: 12,
proyectosActivos: 8,
presupuestoTotal: 45000000,
avancePromedio: 67.5,
alertasActivas: 3,
};
// =============================================================================
// Projects Summary Mock
// =============================================================================
export const mockProjectsSummary: ProjectSummary[] = [
{
id: 'proj-001',
nombre: 'Fraccionamiento Los Alamos',
presupuesto: 15000000,
avanceReal: 72,
avanceProgramado: 68,
spi: 1.06,
cpi: 0.98,
status: 'green',
},
{
id: 'proj-002',
nombre: 'Torre Corporativa Centro',
presupuesto: 8500000,
avanceReal: 45,
avanceProgramado: 52,
spi: 0.87,
cpi: 0.92,
status: 'yellow',
},
{
id: 'proj-003',
nombre: 'Plaza Comercial Norte',
presupuesto: 12000000,
avanceReal: 28,
avanceProgramado: 40,
spi: 0.70,
cpi: 0.85,
status: 'red',
},
{
id: 'proj-004',
nombre: 'Residencial Las Palmas',
presupuesto: 6500000,
avanceReal: 95,
avanceProgramado: 92,
spi: 1.03,
cpi: 1.02,
status: 'green',
},
{
id: 'proj-005',
nombre: 'Bodega Industrial Sur',
presupuesto: 3000000,
avanceReal: 60,
avanceProgramado: 58,
spi: 1.03,
cpi: 0.96,
status: 'green',
},
];
// =============================================================================
// Alerts Mock
// =============================================================================
export const mockAlerts: Alert[] = [
{
id: 'alert-001',
title: 'Retraso en entrega de materiales',
message: 'El proveedor de acero reporta retraso de 5 días en la entrega programada para la semana 12.',
severity: 'warning',
type: 'schedule',
projectId: 'proj-002',
projectName: 'Torre Corporativa Centro',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
acknowledgedAt: undefined,
},
{
id: 'alert-002',
title: 'SPI crítico detectado',
message: 'El proyecto Plaza Comercial Norte tiene un SPI de 0.70, muy por debajo del umbral aceptable.',
severity: 'critical',
type: 'schedule',
projectId: 'proj-003',
projectName: 'Plaza Comercial Norte',
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
acknowledgedAt: undefined,
},
{
id: 'alert-003',
title: 'Inspección de calidad pendiente',
message: 'La inspección de cimentación para el lote 15 está programada para mañana.',
severity: 'info',
type: 'quality',
projectId: 'proj-001',
projectName: 'Fraccionamiento Los Alamos',
createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
acknowledgedAt: undefined,
},
];
// =============================================================================
// Project KPIs Mock (Factory function based on project)
// =============================================================================
export function getMockProjectKPIs(projectId: string): EarnedValueMetrics {
const projectKPIsMap: Record<string, EarnedValueMetrics> = {
'proj-001': {
date: new Date().toISOString(),
spi: 1.06,
cpi: 0.98,
tcpi: 1.02,
sv: 450000,
cv: -120000,
pv: 10200000,
ev: 10800000,
ac: 11000000,
bac: 15000000,
eac: 15306122,
etc: 4306122,
vac: -306122,
percentComplete: 72,
status: 'green',
},
'proj-002': {
date: new Date().toISOString(),
spi: 0.87,
cpi: 0.92,
tcpi: 1.15,
sv: -595000,
cv: -340000,
pv: 4420000,
ev: 3825000,
ac: 4165000,
bac: 8500000,
eac: 9239130,
etc: 5074130,
vac: -739130,
percentComplete: 45,
status: 'yellow',
},
'proj-003': {
date: new Date().toISOString(),
spi: 0.70,
cpi: 0.85,
tcpi: 1.25,
sv: -1440000,
cv: -588235,
pv: 4800000,
ev: 3360000,
ac: 3952941,
bac: 12000000,
eac: 14117647,
etc: 10164706,
vac: -2117647,
percentComplete: 28,
status: 'red',
},
};
return projectKPIsMap[projectId] || {
date: new Date().toISOString(),
spi: 1.0,
cpi: 1.0,
tcpi: 1.0,
sv: 0,
cv: 0,
pv: 1000000,
ev: 1000000,
ac: 1000000,
bac: 5000000,
eac: 5000000,
etc: 4000000,
vac: 0,
percentComplete: 20,
status: 'green',
};
}
// =============================================================================
// Earned Value Data Mock
// =============================================================================
export function getMockEarnedValue(projectId: string): EarnedValueMetrics {
return getMockProjectKPIs(projectId);
}
// =============================================================================
// S-Curve Data Mock
// =============================================================================
export function getMockSCurveData(_projectId: string): SCurveDataPoint[] {
const baseData: SCurveDataPoint[] = [];
let plannedCum = 0;
let earnedCum = 0;
let actualCum = 0;
for (let i = 0; i < 12; i++) {
const plannedValue = 1000000 + Math.random() * 500000;
const earnedValue = i < 8 ? plannedValue * (0.9 + Math.random() * 0.2) : 0;
const actualCost = i < 8 ? earnedValue * (0.95 + Math.random() * 0.1) : 0;
plannedCum += plannedValue;
earnedCum += earnedValue;
actualCum += actualCost;
baseData.push({
date: `2026-${String(i + 1).padStart(2, '0')}-01`,
plannedValue,
earnedValue,
actualCost,
plannedCumulative: plannedCum,
earnedCumulative: earnedCum,
actualCumulative: actualCum,
});
}
return baseData;
}

View File

@ -1,3 +1,8 @@
/**
* Auth Store - Zustand store for authentication state management
* Based on gamilit implementation with session management
*/
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
@ -12,27 +17,120 @@ export interface User {
} }
interface AuthState { interface AuthState {
// State
user: User | null; user: User | null;
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
sessionExpiresAt: number | null;
// Actions
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
setTokens: (accessToken: string, refreshToken: string) => void; setTokens: (accessToken: string, refreshToken: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setInitialized: (initialized: boolean) => void;
logout: () => void; logout: () => void;
clearError: () => void;
checkSession: () => boolean;
extendSession: () => void;
} }
// Session duration: 7 days
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000;
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set) => ({ (set, get) => ({
// Initial state
user: null, user: null,
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }), isLoading: true, // Start as loading until initialized
isInitialized: false,
error: null,
sessionExpiresAt: null,
// Set user and authentication state
setUser: (user) =>
set({
user,
isAuthenticated: !!user,
isLoading: false,
isInitialized: true,
error: null,
}),
// Set tokens and extend session
setTokens: (accessToken, refreshToken) => setTokens: (accessToken, refreshToken) =>
set({ accessToken, refreshToken, isAuthenticated: true }), set({
accessToken,
refreshToken,
isAuthenticated: true,
isLoading: false,
isInitialized: true,
sessionExpiresAt: Date.now() + SESSION_DURATION,
error: null,
}),
// Set loading state
setLoading: (isLoading) => set({ isLoading }),
// Set error
setError: (error) => set({ error, isLoading: false }),
// Set initialized (called after initial session check)
setInitialized: (isInitialized) => set({ isInitialized, isLoading: false }),
// Clear auth state (logout)
logout: () => logout: () =>
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }), set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
sessionExpiresAt: null,
}),
// Clear error
clearError: () => set({ error: null }),
// Check if session is still valid
checkSession: () => {
const { sessionExpiresAt, accessToken } = get();
// No token = not authenticated
if (!accessToken) {
return false;
}
// No expiration set = assume valid (will be validated by API)
if (!sessionExpiresAt) {
return true;
}
// Check if session has expired
const isValid = Date.now() < sessionExpiresAt;
if (!isValid) {
// Session expired - clear auth
get().logout();
}
return isValid;
},
// Extend session expiration
extendSession: () =>
set({
sessionExpiresAt: Date.now() + SESSION_DURATION,
}),
}), }),
{ {
name: 'erp-construccion-auth', name: 'erp-construccion-auth',
@ -41,7 +139,26 @@ export const useAuthStore = create<AuthState>()(
refreshToken: state.refreshToken, refreshToken: state.refreshToken,
user: state.user, user: state.user,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
sessionExpiresAt: state.sessionExpiresAt,
}), }),
// Rehydrate: mark as initialized after loading from storage
onRehydrateStorage: () => (state) => {
if (state) {
// Check if session is still valid after rehydration
const isValid = state.checkSession();
state.setInitialized(true);
if (!isValid) {
state.logout();
}
}
},
} }
) )
); );
// Selector hooks for performance
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
export const useUser = () => useAuthStore((state) => state.user);
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
export const useAuthInitialized = () => useAuthStore((state) => state.isInitialized);

View File

@ -0,0 +1,556 @@
/**
* Finance Types - Contabilidad, CxC, CxP, Flujo de Efectivo
*/
// ============================================================================
// ACCOUNT TYPES (Catálogo de Cuentas)
// ============================================================================
export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense';
export interface Account {
id: string;
tenantId: string;
code: string;
name: string;
type: AccountType;
parentId?: string;
level: number;
isActive: boolean;
allowTransactions: boolean;
balance: number;
children?: Account[];
createdAt: string;
updatedAt: string;
}
export interface AccountFilters {
type?: AccountType;
isActive?: boolean;
level?: number;
search?: string;
page?: number;
limit?: number;
}
export interface CreateAccountDto {
code: string;
name: string;
type: AccountType;
parentId?: string;
allowTransactions?: boolean;
isActive?: boolean;
}
export interface UpdateAccountDto {
code?: string;
name?: string;
parentId?: string;
allowTransactions?: boolean;
isActive?: boolean;
}
// ============================================================================
// ACCOUNTING ENTRY TYPES (Pólizas Contables)
// ============================================================================
export type EntryType = 'income' | 'expense' | 'transfer' | 'adjustment' | 'opening' | 'closing';
export type EntryStatus = 'draft' | 'submitted' | 'approved' | 'posted' | 'cancelled' | 'reversed';
export interface AccountingEntryLine {
id: string;
accountId: string;
account?: Account;
debit: number;
credit: number;
description?: string;
reference?: string;
}
export interface AccountingEntry {
id: string;
tenantId: string;
number: string;
entryNumber: string; // Alias for number
date: string;
type: EntryType;
status: EntryStatus;
reference?: string;
description: string;
lines: AccountingEntryLine[];
totalDebit: number;
totalCredit: number;
isBalanced: boolean;
postedAt?: string;
postedBy?: string;
createdAt: string;
updatedAt: string;
}
export interface EntryFilters {
type?: EntryType;
status?: EntryStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateEntryLineDto {
accountId: string;
debit: number;
credit: number;
description?: string;
reference?: string;
}
export interface CreateEntryDto {
date: string;
type: EntryType;
reference?: string;
description: string;
lines: CreateEntryLineDto[];
}
export interface UpdateEntryDto {
date?: string;
type?: EntryType;
reference?: string;
description?: string;
lines?: CreateEntryLineDto[];
}
// ============================================================================
// TRIAL BALANCE (Balanza de Comprobación)
// ============================================================================
export interface TrialBalanceItem {
accountId: string;
accountCode: string;
accountName: string;
accountType: AccountType;
level: number;
initialDebit: number;
initialCredit: number;
periodDebit: number;
periodCredit: number;
finalDebit: number;
finalCredit: number;
}
export interface TrialBalance {
period: string;
startDate: string;
endDate: string;
items: TrialBalanceItem[];
totals: {
initialDebit: number;
initialCredit: number;
periodDebit: number;
periodCredit: number;
finalDebit: number;
finalCredit: number;
};
}
export interface AccountLedgerItem {
entryId: string;
entryNumber: string;
date: string;
description: string;
reference?: string;
debit: number;
credit: number;
balance: number;
}
export interface AccountLedger {
account: Account;
period: string;
initialBalance: number;
items: AccountLedgerItem[];
finalBalance: number;
}
// ============================================================================
// ACCOUNTS RECEIVABLE (CxC)
// ============================================================================
export type ARStatus = 'pending' | 'partial' | 'paid' | 'overdue' | 'cancelled';
export interface AccountsReceivable {
id: string;
tenantId: string;
partnerId: string;
partnerName: string;
documentType: string;
documentNumber: string;
documentDate: string;
dueDate: string;
originalAmount: number;
paidAmount: number;
balanceAmount: number;
status: ARStatus;
daysOverdue: number;
payments: ARPayment[];
createdAt: string;
updatedAt: string;
}
export interface ARPayment {
id: string;
date: string;
amount: number;
method: string;
reference?: string;
notes?: string;
}
export interface ARFilters {
partnerId?: string;
status?: ARStatus;
overdue?: boolean;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface ARStats {
totalPending: number;
totalOverdue: number;
current: number;
overdue30: number;
overdue60: number;
overdue90Plus: number;
count: number;
}
export interface RegisterARPaymentDto {
date: string;
amount: number;
method: string;
reference?: string;
notes?: string;
}
// ============================================================================
// ACCOUNTS PAYABLE (CxP)
// ============================================================================
export type APStatus = 'pending' | 'scheduled' | 'partial' | 'paid' | 'overdue' | 'cancelled';
export interface AccountsPayable {
id: string;
tenantId: string;
partnerId: string;
partnerName: string;
documentType: string;
documentNumber: string;
documentDate: string;
dueDate: string;
scheduledDate?: string;
originalAmount: number;
paidAmount: number;
balanceAmount: number;
status: APStatus;
daysOverdue: number;
payments: APPayment[];
createdAt: string;
updatedAt: string;
}
export interface APPayment {
id: string;
date: string;
amount: number;
method: string;
reference?: string;
bankAccount?: string;
notes?: string;
}
export interface APFilters {
partnerId?: string;
status?: APStatus;
overdue?: boolean;
scheduled?: boolean;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface APStats {
totalPending: number;
totalOverdue: number;
totalScheduled: number;
current: number;
overdue30: number;
overdue60: number;
overdue90Plus: number;
count: number;
}
export interface ScheduleAPPaymentDto {
scheduledDate: string;
bankAccount?: string;
notes?: string;
}
export interface RegisterAPPaymentDto {
date: string;
amount: number;
method: string;
reference?: string;
bankAccount?: string;
notes?: string;
}
// ============================================================================
// CASH FLOW (Flujo de Efectivo)
// ============================================================================
export interface CashFlowItem {
category: string;
subcategory?: string;
description: string;
amount: number;
type: 'inflow' | 'outflow';
}
export interface CashFlowPeriod {
period: string;
startDate: string;
endDate: string;
openingBalance: number;
totalInflows: number;
totalOutflows: number;
netFlow: number;
closingBalance: number;
items: CashFlowItem[];
}
export interface CashFlowProjection {
periods: CashFlowPeriod[];
summary: {
projectedInflows: number;
projectedOutflows: number;
projectedNetFlow: number;
lowestBalance: number;
lowestBalancePeriod: string;
};
}
// Additional types for Cash Flow Dashboard
export type CashFlowPeriodType = 'day' | 'week' | 'month' | 'quarter' | 'year';
export interface CashFlowCategory {
category: string;
amount: number;
count?: number;
}
export interface CashFlowTransaction {
id: string;
date: string;
description: string;
category: string;
type: 'inflow' | 'outflow';
amount: number;
}
export interface CashFlowSummary {
openingBalance: number;
closingBalance: number;
totalInflows: number;
totalOutflows: number;
netChange: number;
inflowCount: number;
outflowCount: number;
inflowsByCategory: CashFlowCategory[];
outflowsByCategory: CashFlowCategory[];
recentTransactions?: CashFlowTransaction[];
}
export interface DailyProjection {
date: string;
inflows: number;
outflows: number;
net: number;
balance: number;
}
export interface CashFlowForecast {
expectedInflows: number;
expectedOutflows: number;
projectedBalance: number;
dailyProjection: DailyProjection[];
}
// ============================================================================
// INVOICES (Facturación)
// ============================================================================
export type InvoiceStatus = 'draft' | 'sent' | 'viewed' | 'paid' | 'partial' | 'overdue' | 'cancelled';
export interface InvoiceLine {
id: string;
productId?: string;
description: string;
quantity: number;
unitPrice: number;
discount: number;
subtotal: number;
taxAmount: number;
total: number;
}
export interface Invoice {
id: string;
tenantId: string;
number: string;
invoiceNumber: string; // Alias for number
partnerId: string;
partnerName: string;
customerName: string; // Alias for partnerName
partnerRfc?: string;
customerRfc?: string; // Alias for partnerRfc
date: string;
dueDate?: string;
status: InvoiceStatus;
cfdiStatus?: 'none' | 'stamped' | 'cancelled';
cfdiUuid?: string;
lines: InvoiceLine[];
subtotal: number;
discountTotal: number;
taxTotal: number;
total: number;
paidAmount: number;
balanceAmount: number;
currency: string;
notes?: string;
cfdiUse?: string;
paymentMethod?: string;
paymentForm?: string;
uuid?: string;
sentAt?: string;
paidAt?: string;
cancelledAt?: string;
createdAt: string;
updatedAt: string;
}
export interface InvoiceFilters {
partnerId?: string;
status?: InvoiceStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface InvoiceStats {
totalDraft: number;
totalSent: number;
totalPaid: number;
totalOverdue: number;
totalAmount: number;
totalInvoiced: number; // Alias for totalAmount
paidAmount: number;
pendingAmount: number;
totalPending: number; // Alias for pendingAmount
count: number;
}
export interface CreateInvoiceLineDto {
productId?: string;
description: string;
quantity: number;
unitPrice: number;
discount?: number;
}
export interface CreateInvoiceDto {
partnerId: string;
date: string;
dueDate: string;
lines: CreateInvoiceLineDto[];
currency?: string;
notes?: string;
cfdiUse?: string;
paymentMethod?: string;
paymentForm?: string;
}
export interface UpdateInvoiceDto {
partnerId?: string;
date?: string;
dueDate?: string;
lines?: CreateInvoiceLineDto[];
notes?: string;
cfdiUse?: string;
paymentMethod?: string;
paymentForm?: string;
}
// ============================================================================
// CONSTANTS / OPTIONS
// ============================================================================
export const ACCOUNT_TYPE_OPTIONS = [
{ value: 'asset', label: 'Activo', color: 'blue' },
{ value: 'liability', label: 'Pasivo', color: 'red' },
{ value: 'equity', label: 'Capital', color: 'purple' },
{ value: 'income', label: 'Ingreso', color: 'green' },
{ value: 'expense', label: 'Gasto', color: 'orange' },
] as const;
export const ENTRY_TYPE_OPTIONS = [
{ value: 'income', label: 'Ingreso', color: 'green' },
{ value: 'expense', label: 'Egreso', color: 'red' },
{ value: 'transfer', label: 'Traspaso', color: 'blue' },
{ value: 'adjustment', label: 'Ajuste', color: 'yellow' },
{ value: 'opening', label: 'Apertura', color: 'purple' },
{ value: 'closing', label: 'Cierre', color: 'gray' },
] as const;
export const ENTRY_STATUS_OPTIONS = [
{ value: 'draft', label: 'Borrador', color: 'gray' },
{ value: 'submitted', label: 'Enviada', color: 'blue' },
{ value: 'approved', label: 'Aprobada', color: 'yellow' },
{ value: 'posted', label: 'Contabilizada', color: 'green' },
{ value: 'cancelled', label: 'Cancelada', color: 'red' },
{ value: 'reversed', label: 'Reversada', color: 'purple' },
] as const;
export const AR_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pendiente', color: 'yellow' },
{ value: 'partial', label: 'Parcial', color: 'blue' },
{ value: 'paid', label: 'Pagada', color: 'green' },
{ value: 'overdue', label: 'Vencida', color: 'red' },
{ value: 'cancelled', label: 'Cancelada', color: 'gray' },
] as const;
export const AP_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pendiente', color: 'yellow' },
{ value: 'scheduled', label: 'Programada', color: 'blue' },
{ value: 'partial', label: 'Parcial', color: 'purple' },
{ value: 'paid', label: 'Pagada', color: 'green' },
{ value: 'overdue', label: 'Vencida', color: 'red' },
{ value: 'cancelled', label: 'Cancelada', color: 'gray' },
] as const;
export const INVOICE_STATUS_OPTIONS = [
{ value: 'draft', label: 'Borrador', color: 'gray' },
{ value: 'sent', label: 'Enviada', color: 'blue' },
{ value: 'viewed', label: 'Vista', color: 'purple' },
{ value: 'paid', label: 'Pagada', color: 'green' },
{ value: 'partial', label: 'Parcial', color: 'yellow' },
{ value: 'overdue', label: 'Vencida', color: 'red' },
{ value: 'cancelled', label: 'Cancelada', color: 'gray' },
] as const;

View File

@ -135,3 +135,52 @@ export type {
Nullable, Nullable,
Optional, Optional,
} from './common.types'; } from './common.types';
// Finance Types
export type {
AccountType,
Account,
AccountFilters,
CreateAccountDto,
UpdateAccountDto,
EntryStatus,
EntryType,
AccountingEntryLine,
AccountingEntry,
EntryFilters,
CreateEntryDto,
UpdateEntryDto,
ARStatus,
AccountsReceivable,
ARFilters,
ARStats,
RegisterARPaymentDto,
APStatus,
AccountsPayable,
APFilters,
APStats,
RegisterAPPaymentDto,
CashFlowPeriod,
CashFlowPeriodType,
CashFlowCategory,
CashFlowSummary,
CashFlowTransaction,
DailyProjection,
CashFlowForecast,
InvoiceStatus,
InvoiceLine,
Invoice,
InvoiceFilters,
InvoiceStats,
CreateInvoiceDto,
UpdateInvoiceDto,
} from './finance.types';
export {
ACCOUNT_TYPE_OPTIONS,
ENTRY_STATUS_OPTIONS,
ENTRY_TYPE_OPTIONS,
AR_STATUS_OPTIONS,
AP_STATUS_OPTIONS,
INVOICE_STATUS_OPTIONS,
} from './finance.types';

View File

@ -0,0 +1,98 @@
/**
* Auth Cleanup Utilities
* Ensures complete cleanup during logout to prevent race conditions
* Based on gamilit implementation
*/
import { useAuthStore } from '../stores/authStore';
// Flag to prevent race conditions during logout
const LOGOUT_FLAG_KEY = 'erp_is_logging_out';
/**
* Check if logout is in progress
*/
export function isLoggingOut(): boolean {
return localStorage.getItem(LOGOUT_FLAG_KEY) === 'true';
}
/**
* Clear all authentication data from localStorage
*/
export function clearAllAuthData(): void {
// Remove auth-specific keys
localStorage.removeItem('erp-construccion-auth');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('auth-token');
localStorage.removeItem('refresh-token');
// Clear any cached user/session data
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (
key?.startsWith('user-') ||
key?.startsWith('session-') ||
key?.startsWith('erp-')
) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Clear the logout flag
localStorage.removeItem(LOGOUT_FLAG_KEY);
}
/**
* Perform complete logout
* @param backendLogout - Optional function to call backend logout endpoint
*/
export async function performLogout(
backendLogout?: () => Promise<void>
): Promise<void> {
console.log('[authCleanup] Starting logout sequence...');
// CRITICAL: Set logout flag FIRST to prevent race condition
localStorage.setItem(LOGOUT_FLAG_KEY, 'true');
try {
// Try to call backend logout
if (backendLogout) {
try {
await backendLogout();
console.log('[authCleanup] Backend logout successful');
} catch (error) {
console.error('[authCleanup] Backend logout failed:', error);
// Continue with local cleanup even if backend fails
}
}
} finally {
// Always clear local data
clearAllAuthData();
// Clear Zustand store
useAuthStore.getState().logout();
console.log('[authCleanup] Local cleanup complete');
// Force redirect to login
window.location.href = '/auth/login';
}
}
/**
* Check if we should restore session on app load
* Returns false if logout was in progress
*/
export function shouldRestoreSession(): boolean {
if (isLoggingOut()) {
console.log('[authCleanup] Logout was in progress, clearing auth data');
clearAllAuthData();
return false;
}
return true;
}
export default { performLogout, clearAllAuthData, isLoggingOut, shouldRestoreSession };

View File

@ -74,3 +74,11 @@ export {
AUTOSAVE_DEBOUNCE_MS, AUTOSAVE_DEBOUNCE_MS,
MESSAGES, MESSAGES,
} from './constants'; } from './constants';
// Auth Cleanup Utilities
export {
performLogout,
clearAllAuthData,
isLoggingOut,
shouldRestoreSession,
} from './authCleanup';

13
web/src/vite-env.d.ts vendored
View File

@ -1 +1,14 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_TENANT_ID: string;
readonly VITE_APP_ENV: 'development' | 'staging' | 'production';
readonly VITE_SHOW_DEMO_LOGIN?: string;
readonly VITE_DEMO_EMAIL?: string;
readonly VITE_DEMO_PASSWORD?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -20,11 +20,13 @@ export default defineConfig({
}, },
}, },
server: { server: {
port: 5173, // Puerto oficial según DEVENV-PORTS-INVENTORY.yml
port: 3020,
host: true, host: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', // Backend en puerto 3021 según inventario oficial
target: 'http://localhost:3021',
changeOrigin: true, changeOrigin: true,
}, },
}, },