feat: bulk XML upload, period selector, and session persistence
- Add bulk XML CFDI upload support (up to 300MB) - Add period selector component for month/year navigation - Fix session persistence on page refresh (Zustand hydration) - Fix income/expense classification based on tenant RFC - Fix IVA calculation from XML (correct Impuestos element) - Add error handling to reportes page - Support multiple CORS origins - Update reportes service with proper Decimal/BigInt handling - Add RFC to tenant view store for proper CFDI classification - Update README with changelog and new features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,10 +45,7 @@ export default function AlertasPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Alertas"
|
||||
description="Gestiona tus alertas y notificaciones"
|
||||
>
|
||||
<DashboardShell title="Alertas">
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
|
||||
@@ -69,10 +69,7 @@ export default function CalendarioPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Calendario Fiscal"
|
||||
description="Obligaciones fiscales y eventos importantes"
|
||||
>
|
||||
<DashboardShell title="Calendario Fiscal">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* Calendar */}
|
||||
<Card className="lg:col-span-2">
|
||||
|
||||
@@ -1,22 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCfdis } from '@/lib/hooks/use-cfdi';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useCfdis, useCreateCfdi, useCreateManyCfdis, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
|
||||
type CfdiTipo = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||
|
||||
const initialFormData: CreateCfdiData = {
|
||||
uuidFiscal: '',
|
||||
tipo: 'ingreso',
|
||||
serie: '',
|
||||
folio: '',
|
||||
fechaEmision: new Date().toISOString().split('T')[0],
|
||||
fechaTimbrado: new Date().toISOString().split('T')[0],
|
||||
rfcEmisor: '',
|
||||
nombreEmisor: '',
|
||||
rfcReceptor: '',
|
||||
nombreReceptor: '',
|
||||
subtotal: 0,
|
||||
descuento: 0,
|
||||
iva: 0,
|
||||
isrRetenido: 0,
|
||||
ivaRetenido: 0,
|
||||
total: 0,
|
||||
moneda: 'MXN',
|
||||
metodoPago: 'PUE',
|
||||
formaPago: '03',
|
||||
usoCfdi: 'G03',
|
||||
};
|
||||
|
||||
// Helper function to find element regardless of namespace prefix
|
||||
function findElement(doc: Document, localName: string): Element | null {
|
||||
// Try common prefixes first (most reliable for CFDI)
|
||||
const prefixes = ['cfdi', 'tfd', 'pago20', 'pago10', 'nomina12', ''];
|
||||
for (const prefix of prefixes) {
|
||||
const tagName = prefix ? `${prefix}:${localName}` : localName;
|
||||
const el = doc.getElementsByTagName(tagName)[0] as Element;
|
||||
if (el) return el;
|
||||
}
|
||||
|
||||
// Try with wildcard - search all elements by localName
|
||||
const elements = doc.getElementsByTagName('*');
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i].localName === localName) {
|
||||
return elements[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse CFDI XML and extract data
|
||||
function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | null {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||
|
||||
// Check for parse errors
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
if (parseError) {
|
||||
console.error('XML parse error:', parseError.textContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the Comprobante element (root)
|
||||
const comprobante = findElement(doc, 'Comprobante');
|
||||
if (!comprobante) {
|
||||
console.error('No se encontro elemento Comprobante');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get TimbreFiscalDigital for UUID
|
||||
const timbre = findElement(doc, 'TimbreFiscalDigital');
|
||||
const uuid = timbre?.getAttribute('UUID') || '';
|
||||
const fechaTimbradoRaw = timbre?.getAttribute('FechaTimbrado') || '';
|
||||
|
||||
// Get Emisor
|
||||
const emisor = findElement(doc, 'Emisor');
|
||||
const rfcEmisor = emisor?.getAttribute('Rfc') || emisor?.getAttribute('rfc') || '';
|
||||
const nombreEmisor = emisor?.getAttribute('Nombre') || emisor?.getAttribute('nombre') || '';
|
||||
|
||||
// Get Receptor
|
||||
const receptor = findElement(doc, 'Receptor');
|
||||
const rfcReceptor = receptor?.getAttribute('Rfc') || receptor?.getAttribute('rfc') || '';
|
||||
const nombreReceptor = receptor?.getAttribute('Nombre') || receptor?.getAttribute('nombre') || '';
|
||||
const usoCfdi = receptor?.getAttribute('UsoCFDI') || '';
|
||||
|
||||
// Determine type based on tenant RFC
|
||||
// If tenant is emisor -> ingreso (we issued the invoice)
|
||||
// If tenant is receptor -> egreso (we received the invoice)
|
||||
const tenantRfcUpper = tenantRfc.toUpperCase();
|
||||
let tipoFinal: CreateCfdiData['tipo'];
|
||||
if (rfcEmisor.toUpperCase() === tenantRfcUpper) {
|
||||
tipoFinal = 'ingreso';
|
||||
} else if (rfcReceptor.toUpperCase() === tenantRfcUpper) {
|
||||
tipoFinal = 'egreso';
|
||||
} else {
|
||||
// Fallback: use TipoDeComprobante
|
||||
const tipoComprobante = comprobante.getAttribute('TipoDeComprobante') || 'I';
|
||||
tipoFinal = tipoComprobante === 'E' ? 'egreso' : 'ingreso';
|
||||
}
|
||||
|
||||
// Get impuestos - search for the Impuestos element that is direct child of Comprobante
|
||||
// (not the ones inside Conceptos)
|
||||
let totalImpuestosTrasladados = 0;
|
||||
let totalImpuestosRetenidos = 0;
|
||||
|
||||
// Try to get TotalImpuestosTrasladados from Comprobante's direct Impuestos child
|
||||
const allImpuestos = doc.getElementsByTagName('*');
|
||||
for (let i = 0; i < allImpuestos.length; i++) {
|
||||
const el = allImpuestos[i];
|
||||
if (el.localName === 'Impuestos' && el.parentElement?.localName === 'Comprobante') {
|
||||
totalImpuestosTrasladados = parseFloat(el.getAttribute('TotalImpuestosTrasladados') || '0');
|
||||
totalImpuestosRetenidos = parseFloat(el.getAttribute('TotalImpuestosRetenidos') || '0');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: calculate IVA from total - subtotal if not found
|
||||
const subtotal = parseFloat(comprobante.getAttribute('SubTotal') || '0');
|
||||
const descuento = parseFloat(comprobante.getAttribute('Descuento') || '0');
|
||||
const total = parseFloat(comprobante.getAttribute('Total') || '0');
|
||||
|
||||
if (totalImpuestosTrasladados === 0 && total > subtotal) {
|
||||
totalImpuestosTrasladados = Math.max(0, total - subtotal + descuento + totalImpuestosRetenidos);
|
||||
}
|
||||
|
||||
// Get retenciones breakdown
|
||||
let isrRetenido = 0;
|
||||
let ivaRetenido = 0;
|
||||
const retenciones = doc.querySelectorAll('[localName="Retencion"], Retencion, cfdi\\:Retencion');
|
||||
retenciones.forEach((ret: Element) => {
|
||||
const impuesto = ret.getAttribute('Impuesto');
|
||||
const importe = parseFloat(ret.getAttribute('Importe') || '0');
|
||||
if (impuesto === '001') isrRetenido = importe; // ISR
|
||||
if (impuesto === '002') ivaRetenido = importe; // IVA
|
||||
});
|
||||
|
||||
// Parse dates - handle both ISO format and datetime format
|
||||
const fechaEmisionRaw = comprobante.getAttribute('Fecha') || '';
|
||||
const fechaEmision = fechaEmisionRaw.includes('T') ? fechaEmisionRaw.split('T')[0] : fechaEmisionRaw;
|
||||
const fechaTimbrado = fechaTimbradoRaw.includes('T') ? fechaTimbradoRaw.split('T')[0] : fechaTimbradoRaw;
|
||||
|
||||
// Validate required fields
|
||||
if (!uuid) {
|
||||
console.error('UUID no encontrado en el XML');
|
||||
return null;
|
||||
}
|
||||
if (!rfcEmisor || !rfcReceptor) {
|
||||
console.error('RFC emisor o receptor no encontrado');
|
||||
return null;
|
||||
}
|
||||
if (!fechaEmision) {
|
||||
console.error('Fecha de emision no encontrada');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
uuidFiscal: uuid.toUpperCase(),
|
||||
tipo: tipoFinal,
|
||||
serie: comprobante.getAttribute('Serie') || '',
|
||||
folio: comprobante.getAttribute('Folio') || '',
|
||||
fechaEmision,
|
||||
fechaTimbrado: fechaTimbrado || fechaEmision,
|
||||
rfcEmisor,
|
||||
nombreEmisor: nombreEmisor || 'Sin nombre',
|
||||
rfcReceptor,
|
||||
nombreReceptor: nombreReceptor || 'Sin nombre',
|
||||
subtotal,
|
||||
descuento,
|
||||
iva: totalImpuestosTrasladados,
|
||||
isrRetenido,
|
||||
ivaRetenido,
|
||||
total,
|
||||
moneda: comprobante.getAttribute('Moneda') || 'MXN',
|
||||
tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'),
|
||||
metodoPago: comprobante.getAttribute('MetodoPago') || '',
|
||||
formaPago: comprobante.getAttribute('FormaPago') || '',
|
||||
usoCfdi,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing XML:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function CfdiPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { viewingTenantRfc } = useTenantViewStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Get the effective tenant RFC (viewing tenant or user's tenant)
|
||||
const tenantRfc = viewingTenantRfc || user?.tenantRfc || '';
|
||||
const [filters, setFilters] = useState<CfdiFilters>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showBulkForm, setShowBulkForm] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||
const [bulkData, setBulkData] = useState('');
|
||||
const [xmlFiles, setXmlFiles] = useState<File[]>([]);
|
||||
const [parsedXmls, setParsedXmls] = useState<{ file: string; data: CreateCfdiData | null; error?: string }[]>([]);
|
||||
const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml');
|
||||
|
||||
const { data, isLoading } = useCfdis(filters);
|
||||
const createCfdi = useCreateCfdi();
|
||||
const createManyCfdis = useCreateManyCfdis();
|
||||
const deleteCfdi = useDeleteCfdi();
|
||||
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'contador';
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
@@ -26,6 +230,112 @@ export default function CfdiPage() {
|
||||
setFilters({ ...filters, tipo, page: 1 });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await createCfdi.mutateAsync(formData);
|
||||
setFormData(initialFormData);
|
||||
setShowForm(false);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.message || 'Error al crear CFDI');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const cfdis = JSON.parse(bulkData);
|
||||
if (!Array.isArray(cfdis)) {
|
||||
throw new Error('El formato debe ser un array de CFDIs');
|
||||
}
|
||||
const result = await createManyCfdis.mutateAsync(cfdis);
|
||||
alert(`Se crearon ${result.count} CFDIs exitosamente`);
|
||||
setBulkData('');
|
||||
setShowBulkForm(false);
|
||||
} catch (error: any) {
|
||||
alert(error.message || 'Error al procesar CFDIs');
|
||||
}
|
||||
};
|
||||
|
||||
const handleXmlFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
setXmlFiles(files);
|
||||
|
||||
// Parse each XML file
|
||||
const parsed = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = parseCfdiXml(text, tenantRfc);
|
||||
if (!data) {
|
||||
return { file: file.name, data: null, error: 'No se pudo parsear el XML' };
|
||||
}
|
||||
if (!data.uuidFiscal) {
|
||||
return { file: file.name, data: null, error: 'UUID no encontrado en el XML' };
|
||||
}
|
||||
return { file: file.name, data };
|
||||
} catch (error) {
|
||||
return { file: file.name, data: null, error: 'Error al leer el archivo' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setParsedXmls(parsed);
|
||||
};
|
||||
|
||||
const handleXmlBulkSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validCfdis = parsedXmls
|
||||
.filter((p) => p.data !== null)
|
||||
.map((p) => p.data as CreateCfdiData);
|
||||
|
||||
if (validCfdis.length === 0) {
|
||||
alert('No hay CFDIs validos para cargar');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createManyCfdis.mutateAsync(validCfdis);
|
||||
alert(`Se crearon ${result.count} CFDIs exitosamente`);
|
||||
setXmlFiles([]);
|
||||
setParsedXmls([]);
|
||||
setShowBulkForm(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.message || 'Error al cargar CFDIs');
|
||||
}
|
||||
};
|
||||
|
||||
const clearXmlFiles = () => {
|
||||
setXmlFiles([]);
|
||||
setParsedXmls([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('¿Eliminar este CFDI?')) {
|
||||
try {
|
||||
await deleteCfdi.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting CFDI:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
const subtotal = formData.subtotal || 0;
|
||||
const descuento = formData.descuento || 0;
|
||||
const iva = formData.iva || 0;
|
||||
const isrRetenido = formData.isrRetenido || 0;
|
||||
const ivaRetenido = formData.ivaRetenido || 0;
|
||||
return subtotal - descuento + iva - isrRetenido - ivaRetenido;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
@@ -39,6 +349,14 @@ export default function CfdiPage() {
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const generateUUID = () => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
}).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Gestion de CFDI" />
|
||||
@@ -81,10 +399,378 @@ export default function CfdiPage() {
|
||||
Egresos
|
||||
</Button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Agregar
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Carga Masiva
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add CFDI Form */}
|
||||
{showForm && canEdit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Agregar CFDI</CardTitle>
|
||||
<CardDescription>Ingresa los datos del comprobante fiscal</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>UUID Fiscal</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.uuidFiscal}
|
||||
onChange={(e) => setFormData({ ...formData, uuidFiscal: e.target.value.toUpperCase() })}
|
||||
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => setFormData({ ...formData, uuidFiscal: generateUUID() })}>
|
||||
Gen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tipo</Label>
|
||||
<Select
|
||||
value={formData.tipo}
|
||||
onValueChange={(v) => setFormData({ ...formData, tipo: v as CfdiTipo })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ingreso">Ingreso</SelectItem>
|
||||
<SelectItem value="egreso">Egreso</SelectItem>
|
||||
<SelectItem value="traslado">Traslado</SelectItem>
|
||||
<SelectItem value="nomina">Nomina</SelectItem>
|
||||
<SelectItem value="pago">Pago</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Serie</Label>
|
||||
<Input
|
||||
value={formData.serie}
|
||||
onChange={(e) => setFormData({ ...formData, serie: e.target.value })}
|
||||
placeholder="A"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Folio</Label>
|
||||
<Input
|
||||
value={formData.folio}
|
||||
onChange={(e) => setFormData({ ...formData, folio: e.target.value })}
|
||||
placeholder="001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Fecha Emision</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.fechaEmision}
|
||||
onChange={(e) => setFormData({ ...formData, fechaEmision: e.target.value, fechaTimbrado: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fecha Timbrado</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.fechaTimbrado}
|
||||
onChange={(e) => setFormData({ ...formData, fechaTimbrado: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="p-4 border rounded-lg space-y-3">
|
||||
<h4 className="font-medium">Emisor</h4>
|
||||
<div className="space-y-2">
|
||||
<Label>RFC Emisor</Label>
|
||||
<Input
|
||||
value={formData.rfcEmisor}
|
||||
onChange={(e) => setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })}
|
||||
placeholder="XAXX010101000"
|
||||
maxLength={13}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nombre Emisor</Label>
|
||||
<Input
|
||||
value={formData.nombreEmisor}
|
||||
onChange={(e) => setFormData({ ...formData, nombreEmisor: e.target.value })}
|
||||
placeholder="Empresa Emisora SA de CV"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg space-y-3">
|
||||
<h4 className="font-medium">Receptor</h4>
|
||||
<div className="space-y-2">
|
||||
<Label>RFC Receptor</Label>
|
||||
<Input
|
||||
value={formData.rfcReceptor}
|
||||
onChange={(e) => setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })}
|
||||
placeholder="XAXX010101000"
|
||||
maxLength={13}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nombre Receptor</Label>
|
||||
<Input
|
||||
value={formData.nombreReceptor}
|
||||
onChange={(e) => setFormData({ ...formData, nombreReceptor: e.target.value })}
|
||||
placeholder="Empresa Receptora SA de CV"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Subtotal</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.subtotal}
|
||||
onChange={(e) => setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Descuento</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.descuento}
|
||||
onChange={(e) => setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>IVA</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.iva}
|
||||
onChange={(e) => setFormData({ ...formData, iva: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>ISR Ret.</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.isrRetenido}
|
||||
onChange={(e) => setFormData({ ...formData, isrRetenido: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>IVA Ret.</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.ivaRetenido}
|
||||
onChange={(e) => setFormData({ ...formData, ivaRetenido: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Total</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.total || calculateTotal()}
|
||||
onChange={(e) => setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={createCfdi.isPending}>
|
||||
{createCfdi.isPending ? 'Guardando...' : 'Guardar CFDI'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bulk Upload Form */}
|
||||
{showBulkForm && canEdit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Carga Masiva de CFDIs</CardTitle>
|
||||
<CardDescription>Sube archivos XML o pega datos en formato JSON</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Mode selector */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant={uploadMode === 'xml' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setUploadMode('xml')}
|
||||
>
|
||||
<FileUp className="h-4 w-4 mr-1" />
|
||||
Subir XMLs
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={uploadMode === 'json' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setUploadMode('json')}
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{uploadMode === 'xml' ? (
|
||||
<form onSubmit={handleXmlBulkSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Archivos XML de CFDI</Label>
|
||||
<div className="border-2 border-dashed rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xml"
|
||||
multiple
|
||||
onChange={handleXmlFilesChange}
|
||||
className="hidden"
|
||||
id="xml-upload"
|
||||
/>
|
||||
<label htmlFor="xml-upload" className="cursor-pointer">
|
||||
<FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Haz clic para seleccionar archivos XML o arrastralos aqui
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Puedes seleccionar multiples archivos
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show parsed results */}
|
||||
{parsedXmls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Archivos procesados ({parsedXmls.filter(p => p.data).length} validos de {parsedXmls.length})</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}>
|
||||
Limpiar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||
{parsedXmls.map((parsed, idx) => (
|
||||
<div key={idx} className="p-2 flex items-center gap-2 text-sm">
|
||||
{parsed.data ? (
|
||||
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{parsed.file}</p>
|
||||
{parsed.data ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">{parsed.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createManyCfdis.isPending || parsedXmls.filter(p => p.data).length === 0}
|
||||
>
|
||||
{createManyCfdis.isPending ? 'Procesando...' : `Cargar ${parsedXmls.filter(p => p.data).length} CFDIs`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleBulkSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Datos JSON</Label>
|
||||
<textarea
|
||||
className="w-full h-48 p-3 border rounded-lg font-mono text-sm bg-background"
|
||||
value={bulkData}
|
||||
onChange={(e) => setBulkData(e.target.value)}
|
||||
placeholder={`[
|
||||
{
|
||||
"uuidFiscal": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||
"tipo": "ingreso",
|
||||
"fechaEmision": "2025-01-15",
|
||||
"fechaTimbrado": "2025-01-15",
|
||||
"rfcEmisor": "XAXX010101000",
|
||||
"nombreEmisor": "Empresa SA de CV",
|
||||
"rfcReceptor": "XAXX010101001",
|
||||
"nombreReceptor": "Cliente SA de CV",
|
||||
"subtotal": 10000,
|
||||
"iva": 1600,
|
||||
"total": 11600
|
||||
}
|
||||
]`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={createManyCfdis.isPending}>
|
||||
{createManyCfdis.isPending ? 'Procesando...' : 'Cargar CFDIs'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -109,10 +795,12 @@ export default function CfdiPage() {
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Fecha</th>
|
||||
<th className="pb-3 font-medium">Tipo</th>
|
||||
<th className="pb-3 font-medium">Serie/Folio</th>
|
||||
<th className="pb-3 font-medium">Emisor/Receptor</th>
|
||||
<th className="pb-3 font-medium">Folio</th>
|
||||
<th className="pb-3 font-medium">Emisor</th>
|
||||
<th className="pb-3 font-medium">Receptor</th>
|
||||
<th className="pb-3 font-medium text-right">Total</th>
|
||||
<th className="pb-3 font-medium">Estado</th>
|
||||
{canEdit && <th className="pb-3 font-medium"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">
|
||||
@@ -131,19 +819,25 @@ export default function CfdiPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
{cfdi.serie || '-'}-{cfdi.folio || '-'}
|
||||
{cfdi.folio || '-'}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{cfdi.tipo === 'ingreso'
|
||||
? cfdi.nombreReceptor
|
||||
: cfdi.nombreEmisor}
|
||||
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreEmisor}>
|
||||
{cfdi.nombreEmisor}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cfdi.tipo === 'ingreso'
|
||||
? cfdi.rfcReceptor
|
||||
: cfdi.rfcEmisor}
|
||||
{cfdi.rfcEmisor}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div>
|
||||
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreReceptor}>
|
||||
{cfdi.nombreReceptor}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cfdi.rfcReceptor}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -161,6 +855,18 @@ export default function CfdiPage() {
|
||||
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
||||
</span>
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(cfdi.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTenants, useCreateTenant } from '@/lib/hooks/use-tenants';
|
||||
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
|
||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Building, Plus, Users, Eye, Calendar } from 'lucide-react';
|
||||
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X } from 'lucide-react';
|
||||
import type { Tenant } from '@/lib/api/tenants';
|
||||
|
||||
type PlanType = 'starter' | 'business' | 'professional' | 'enterprise';
|
||||
|
||||
export default function ClientesPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { data: tenants, isLoading } = useTenants();
|
||||
const createTenant = useCreateTenant();
|
||||
const updateTenant = useUpdateTenant();
|
||||
const deleteTenant = useDeleteTenant();
|
||||
const { setViewingTenant } = useTenantViewStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
|
||||
const [formData, setFormData] = useState<{
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
plan: PlanType;
|
||||
}>({
|
||||
nombre: '',
|
||||
rfc: '',
|
||||
plan: 'starter' as const,
|
||||
plan: 'starter',
|
||||
});
|
||||
|
||||
// Only admins can access this page
|
||||
@@ -47,17 +61,49 @@ export default function ClientesPage() {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await createTenant.mutateAsync(formData);
|
||||
if (editingTenant) {
|
||||
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
|
||||
setEditingTenant(null);
|
||||
} else {
|
||||
await createTenant.mutateAsync(formData);
|
||||
}
|
||||
setFormData({ nombre: '', rfc: '', plan: 'starter' });
|
||||
setShowForm(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating tenant:', error);
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (tenant: Tenant) => {
|
||||
setEditingTenant(tenant);
|
||||
setFormData({
|
||||
nombre: tenant.nombre,
|
||||
rfc: tenant.rfc,
|
||||
plan: tenant.plan as PlanType,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (tenant: Tenant) => {
|
||||
if (confirm(`¿Eliminar el cliente "${tenant.nombre}"? Esta acción desactivará el cliente.`)) {
|
||||
try {
|
||||
await deleteTenant.mutateAsync(tenant.id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting tenant:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingTenant(null);
|
||||
setFormData({ nombre: '', rfc: '', plan: 'starter' });
|
||||
};
|
||||
|
||||
const handleViewClient = (tenantId: string, tenantName: string) => {
|
||||
setViewingTenant(tenantId, tenantName);
|
||||
window.location.href = '/dashboard';
|
||||
queryClient.invalidateQueries();
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -126,14 +172,25 @@ export default function ClientesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Add Client Form */}
|
||||
{/* Add/Edit Client Form */}
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Nuevo Cliente</CardTitle>
|
||||
<CardDescription>
|
||||
Registra un nuevo cliente para gestionar su facturación
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{editingTenant
|
||||
? 'Modifica los datos del cliente'
|
||||
: 'Registra un nuevo cliente para gestionar su facturación'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -157,6 +214,7 @@ export default function ClientesPage() {
|
||||
placeholder="XAXX010101000"
|
||||
maxLength={13}
|
||||
required
|
||||
disabled={!!editingTenant} // Can't change RFC after creation
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,8 +222,8 @@ export default function ClientesPage() {
|
||||
<Label htmlFor="plan">Plan</Label>
|
||||
<Select
|
||||
value={formData.plan}
|
||||
onValueChange={(value: 'starter' | 'business' | 'professional' | 'enterprise') =>
|
||||
setFormData({ ...formData, plan: value })
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, plan: value as PlanType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -180,11 +238,13 @@ export default function ClientesPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
|
||||
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={createTenant.isPending}>
|
||||
{createTenant.isPending ? 'Creando...' : 'Crear Cliente'}
|
||||
<Button type="submit" disabled={createTenant.isPending || updateTenant.isPending}>
|
||||
{editingTenant
|
||||
? (updateTenant.isPending ? 'Guardando...' : 'Guardar Cambios')
|
||||
: (createTenant.isPending ? 'Creando...' : 'Crear Cliente')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -223,7 +283,7 @@ export default function ClientesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -234,14 +294,33 @@ export default function ClientesPage() {
|
||||
<span>{formatDate(tenant.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewClient(tenant.id, tenant.nombre)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Ver
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewClient(tenant.id, tenant.nombre)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Ver
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(tenant)}
|
||||
title="Editar"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(tenant)}
|
||||
title="Eliminar"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { KpiCard } from '@/components/charts/kpi-card';
|
||||
import { BarChart } from '@/components/charts/bar-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PeriodSelector } from '@/components/period-selector';
|
||||
import { useKpis, useIngresosEgresos, useAlertas, useResumenFiscal } from '@/lib/hooks/use-dashboard';
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -15,13 +17,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const [año, setAño] = useState(new Date().getFullYear());
|
||||
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||
|
||||
const { data: kpis, isLoading: kpisLoading } = useKpis(currentYear, currentMonth);
|
||||
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(currentYear);
|
||||
const { data: kpis, isLoading: kpisLoading } = useKpis(año, mes);
|
||||
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(año);
|
||||
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
|
||||
const { data: resumenFiscal } = useResumenFiscal(currentYear, currentMonth);
|
||||
const { data: resumenFiscal } = useResumenFiscal(año, mes);
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
@@ -32,7 +34,14 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Dashboard" />
|
||||
<Header title="Dashboard">
|
||||
<PeriodSelector
|
||||
año={año}
|
||||
mes={mes}
|
||||
onAñoChange={setAño}
|
||||
onMesChange={setMes}
|
||||
/>
|
||||
</Header>
|
||||
<main className="p-6 space-y-6">
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -5,20 +5,20 @@ import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KpiCard } from '@/components/charts/kpi-card';
|
||||
import { PeriodSelector } from '@/components/period-selector';
|
||||
import { useIvaMensual, useResumenIva, useResumenIsr } from '@/lib/hooks/use-impuestos';
|
||||
import { Calculator, TrendingUp, TrendingDown, Receipt } from 'lucide-react';
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
|
||||
export default function ImpuestosPage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const [año] = useState(currentYear);
|
||||
const [año, setAño] = useState(new Date().getFullYear());
|
||||
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||
const [activeTab, setActiveTab] = useState<'iva' | 'isr'>('iva');
|
||||
|
||||
const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año);
|
||||
const { data: resumenIva } = useResumenIva(año, currentMonth);
|
||||
const { data: resumenIsr } = useResumenIsr(año, currentMonth);
|
||||
const { data: resumenIva } = useResumenIva(año, mes);
|
||||
const { data: resumenIsr } = useResumenIsr(año, mes);
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', {
|
||||
@@ -29,7 +29,14 @@ export default function ImpuestosPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Control de Impuestos" />
|
||||
<Header title="Control de Impuestos">
|
||||
<PeriodSelector
|
||||
año={año}
|
||||
mes={mes}
|
||||
onAñoChange={setAño}
|
||||
onMesChange={setMes}
|
||||
/>
|
||||
</Header>
|
||||
<main className="p-6 space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -17,17 +17,27 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { isAuthenticated, _hasHydrated } = useAuthStore();
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
const currentTheme = themes[theme];
|
||||
const layout = currentTheme.layout;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
// Solo verificar autenticación después de que el store se rehidrate
|
||||
if (_hasHydrated && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
}, [isAuthenticated, _hasHydrated, router]);
|
||||
|
||||
// Mostrar loading mientras se rehidrata el store
|
||||
if (!_hasHydrated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="animate-pulse text-muted-foreground">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
|
||||
@@ -4,26 +4,33 @@ import { useState } from 'react';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { PeriodSelector } from '@/components/period-selector';
|
||||
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes';
|
||||
import { BarChart } from '@/components/charts/bar-chart';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react';
|
||||
|
||||
export default function ReportesPage() {
|
||||
const [año] = useState(new Date().getFullYear());
|
||||
const [año, setAño] = useState(new Date().getFullYear());
|
||||
const fechaInicio = `${año}-01-01`;
|
||||
const fechaFin = `${año}-12-31`;
|
||||
|
||||
const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin);
|
||||
const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin);
|
||||
const { data: comparativo, isLoading: loadingComp } = useComparativo(año);
|
||||
const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
|
||||
const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
|
||||
const { data: estadoResultados, isLoading: loadingER, error: errorER } = useEstadoResultados(fechaInicio, fechaFin);
|
||||
const { data: flujoEfectivo, isLoading: loadingFE, error: errorFE } = useFlujoEfectivo(fechaInicio, fechaFin);
|
||||
const { data: comparativo, isLoading: loadingComp, error: errorComp } = useComparativo(año);
|
||||
const { data: clientes, error: errorClientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
|
||||
const { data: proveedores, error: errorProveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Reportes"
|
||||
description="Analisis financiero y reportes fiscales"
|
||||
headerContent={
|
||||
<PeriodSelector
|
||||
año={año}
|
||||
onAñoChange={setAño}
|
||||
showMonth={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tabs defaultValue="estado-resultados" className="space-y-4">
|
||||
<TabsList>
|
||||
@@ -36,7 +43,11 @@ export default function ReportesPage() {
|
||||
<TabsContent value="estado-resultados" className="space-y-4">
|
||||
{loadingER ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : estadoResultados ? (
|
||||
) : errorER ? (
|
||||
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
|
||||
) : !estadoResultados ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
@@ -118,13 +129,17 @@ export default function ReportesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="flujo-efectivo" className="space-y-4">
|
||||
{loadingFE ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : flujoEfectivo ? (
|
||||
) : errorFE ? (
|
||||
<div className="text-center py-8 text-destructive">Error: {(errorFE as Error).message}</div>
|
||||
) : !flujoEfectivo ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay datos de flujo de efectivo</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
@@ -159,28 +174,26 @@ export default function ReportesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flujo de Efectivo Mensual</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={flujoEfectivo.entradas.map((e, i) => ({
|
||||
mes: e.concepto,
|
||||
ingresos: e.monto,
|
||||
egresos: flujoEfectivo.salidas[i]?.monto || 0,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BarChart
|
||||
title="Flujo de Efectivo Mensual"
|
||||
data={flujoEfectivo.entradas.map((e, i) => ({
|
||||
mes: e.concepto,
|
||||
ingresos: e.monto,
|
||||
egresos: flujoEfectivo.salidas[i]?.monto || 0,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comparativo" className="space-y-4">
|
||||
{loadingComp ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
) : comparativo ? (
|
||||
) : errorComp ? (
|
||||
<div className="text-center py-8 text-destructive">Error: {(errorComp as Error).message}</div>
|
||||
) : !comparativo ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No hay datos comparativos</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
@@ -213,69 +226,77 @@ export default function ReportesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Comparativo Mensual {año}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={comparativo.periodos.map((mes, i) => ({
|
||||
mes,
|
||||
ingresos: comparativo.ingresos[i],
|
||||
egresos: comparativo.egresos[i],
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BarChart
|
||||
title={`Comparativo Mensual ${año}`}
|
||||
data={comparativo.periodos.map((mes, i) => ({
|
||||
mes,
|
||||
ingresos: comparativo.ingresos[i],
|
||||
egresos: comparativo.egresos[i],
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="concentrado" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Clientes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{clientes?.slice(0, 10).map((c, i) => (
|
||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{c.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
|
||||
</div>
|
||||
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Proveedores
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{proveedores?.slice(0, 10).map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{p.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
|
||||
</div>
|
||||
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{errorClientes || errorProveedores ? (
|
||||
<div className="text-center py-8 text-destructive">
|
||||
Error: {((errorClientes || errorProveedores) as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Clientes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{clientes && clientes.length > 0 ? (
|
||||
clientes.slice(0, 10).map((c, i) => (
|
||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{c.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
|
||||
</div>
|
||||
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">Sin clientes</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Proveedores
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{proveedores && proveedores.length > 0 ? (
|
||||
proveedores.slice(0, 10).map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{p.nombre}</div>
|
||||
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
|
||||
</div>
|
||||
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">Sin proveedores</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function UsuariosPage() {
|
||||
const deleteUsuario = useDeleteUsuario();
|
||||
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const });
|
||||
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: 'admin' | 'contador' | 'visor' }>({ email: '', nombre: '', role: 'visor' });
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -52,10 +52,7 @@ export default function UsuariosPage() {
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="Usuarios"
|
||||
description="Gestiona los usuarios de tu empresa"
|
||||
>
|
||||
<DashboardShell title="Usuarios">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -103,7 +100,7 @@ export default function UsuariosPage() {
|
||||
<Label htmlFor="role">Rol</Label>
|
||||
<Select
|
||||
value={inviteForm.role}
|
||||
onValueChange={(v: 'admin' | 'contador' | 'visor') => setInviteForm({ ...inviteForm, role: v })}
|
||||
onValueChange={(v) => setInviteForm({ ...inviteForm, role: v as 'admin' | 'contador' | 'visor' })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
||||
Reference in New Issue
Block a user