[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:
parent
55261598a2
commit
a03bed842f
@ -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
1762
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
118
web/src/components/auth/ProtectedRoute.tsx
Normal file
118
web/src/components/auth/ProtectedRoute.tsx
Normal 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;
|
||||||
5
web/src/components/auth/index.ts
Normal file
5
web/src/components/auth/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Auth Components Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ProtectedRoute, PublicRoute } from './ProtectedRoute';
|
||||||
188
web/src/components/contracts/AddendaModal.tsx
Normal file
188
web/src/components/contracts/AddendaModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
web/src/components/contracts/ContractForm.tsx
Normal file
321
web/src/components/contracts/ContractForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
web/src/components/contracts/PartidaModal.tsx
Normal file
163
web/src/components/contracts/PartidaModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/components/contracts/index.ts
Normal file
7
web/src/components/contracts/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Contracts Components Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ContractForm } from './ContractForm';
|
||||||
|
export { AddendaModal } from './AddendaModal';
|
||||||
|
export { PartidaModal } from './PartidaModal';
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
649
web/src/hooks/useFinance.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
160
web/src/hooks/useSession.ts
Normal 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;
|
||||||
@ -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>
|
||||||
|
|||||||
497
web/src/pages/admin/finanzas/CuentasContablesPage.tsx
Normal file
497
web/src/pages/admin/finanzas/CuentasContablesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
472
web/src/pages/admin/finanzas/CuentasPorCobrarPage.tsx
Normal file
472
web/src/pages/admin/finanzas/CuentasPorCobrarPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
454
web/src/pages/admin/finanzas/CuentasPorPagarPage.tsx
Normal file
454
web/src/pages/admin/finanzas/CuentasPorPagarPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
422
web/src/pages/admin/finanzas/FacturasPage.tsx
Normal file
422
web/src/pages/admin/finanzas/FacturasPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
web/src/pages/admin/finanzas/FlujoEfectivoPage.tsx
Normal file
279
web/src/pages/admin/finanzas/FlujoEfectivoPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
369
web/src/pages/admin/finanzas/PolizasPage.tsx
Normal file
369
web/src/pages/admin/finanzas/PolizasPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/pages/admin/finanzas/index.ts
Normal file
10
web/src/pages/admin/finanzas/index.ts
Normal 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';
|
||||||
@ -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 */}
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
133
web/src/services/finance/accounting.api.ts
Normal file
133
web/src/services/finance/accounting.api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
66
web/src/services/finance/ap.api.ts
Normal file
66
web/src/services/finance/ap.api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
50
web/src/services/finance/ar.api.ts
Normal file
50
web/src/services/finance/ar.api.ts
Normal 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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
41
web/src/services/finance/cash-flow.api.ts
Normal file
41
web/src/services/finance/cash-flow.api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
9
web/src/services/finance/index.ts
Normal file
9
web/src/services/finance/index.ts
Normal 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';
|
||||||
95
web/src/services/finance/invoices.api.ts
Normal file
95
web/src/services/finance/invoices.api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
556
web/src/services/mockData.construccion.ts
Normal file
556
web/src/services/mockData.construccion.ts
Normal 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;
|
||||||
|
}
|
||||||
678
web/src/services/mockData.modules.ts
Normal file
678
web/src/services/mockData.modules.ts
Normal 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',
|
||||||
|
},
|
||||||
|
];
|
||||||
240
web/src/services/mockData.ts
Normal file
240
web/src/services/mockData.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
556
web/src/types/finance.types.ts
Normal file
556
web/src/types/finance.types.ts
Normal 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;
|
||||||
@ -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';
|
||||||
|
|||||||
98
web/src/utils/authCleanup.ts
Normal file
98
web/src/utils/authCleanup.ts
Normal 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 };
|
||||||
@ -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
13
web/src/vite-env.d.ts
vendored
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user