|
|
@@ -1,929 +1,90 @@
|
|
|
"use client"
|
|
|
|
|
|
-import { useState, useCallback, useMemo } from "react"
|
|
|
-import { Button } from "@/components/ui/button"
|
|
|
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
|
-import { Input } from "@/components/ui/input"
|
|
|
-import { Label } from "@/components/ui/label"
|
|
|
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
|
-import { Textarea } from "@/components/ui/textarea"
|
|
|
-import { toast } from "sonner"
|
|
|
-import { Trash2, Plus, Download } from "lucide-react"
|
|
|
-
|
|
|
-// Interfaces TypeScript para tipos estrictos
|
|
|
-interface InfoTributaria {
|
|
|
- ambiente: string
|
|
|
- tipoEmision: string
|
|
|
- razonSocial: string
|
|
|
- nombreComercial: string
|
|
|
- ruc: string
|
|
|
- claveAcceso: string
|
|
|
- estab: string
|
|
|
- ptoEmi: string
|
|
|
- secuencial: string
|
|
|
- dirMatriz: string
|
|
|
-}
|
|
|
-
|
|
|
-interface InfoFactura {
|
|
|
- fechaEmision: string
|
|
|
- dirEstablecimiento: string
|
|
|
- obligadoContabilidad: string
|
|
|
- tipoIdentificacionComprador: string
|
|
|
- razonSocialComprador: string
|
|
|
- identificacionComprador: string
|
|
|
- totalSinImpuestos: string
|
|
|
- totalDescuento: string
|
|
|
- importeTotal: string
|
|
|
- formaPago: string
|
|
|
- // Campos adicionales para infoAdicional
|
|
|
- direccionComprador: string
|
|
|
- emailComprador: string
|
|
|
- telefonoComprador: string
|
|
|
- // Para pagos
|
|
|
- plazo: string
|
|
|
- unidadTiempo: string
|
|
|
-}
|
|
|
-
|
|
|
-interface DetalleItem {
|
|
|
- id: string
|
|
|
- codigoPrincipal: string
|
|
|
- codigoAuxiliar: string
|
|
|
- descripcion: string
|
|
|
- cantidad: string
|
|
|
- precioUnitario: string
|
|
|
- descuento: string
|
|
|
- precioTotalSinImpuesto: string
|
|
|
- codigoPorcentaje: string
|
|
|
- tarifa: string
|
|
|
- baseImponible: string
|
|
|
- valorImpuesto: string
|
|
|
-}
|
|
|
-
|
|
|
-interface TotalImpuesto {
|
|
|
- codigo: string
|
|
|
- codigoPorcentaje: string
|
|
|
- baseImponible: string
|
|
|
- valor: string
|
|
|
-}
|
|
|
-
|
|
|
-interface FacturaData {
|
|
|
- infoTributaria: InfoTributaria
|
|
|
- infoFactura: InfoFactura
|
|
|
- detalles: DetalleItem[]
|
|
|
- totalesImpuestos: TotalImpuesto[]
|
|
|
-}
|
|
|
-
|
|
|
-// Componente para item de detalle
|
|
|
-function DetalleItemForm({
|
|
|
- item,
|
|
|
- onChange,
|
|
|
- onRemove
|
|
|
-}: {
|
|
|
- item: DetalleItem
|
|
|
- onChange: (item: DetalleItem) => void
|
|
|
- onRemove: () => void
|
|
|
-}) {
|
|
|
- const handleChange = (field: keyof DetalleItem, value: string) => {
|
|
|
- const updatedItem = { ...item, [field]: value }
|
|
|
-
|
|
|
- // Cálculos automáticos
|
|
|
- if (field === 'cantidad' || field === 'precioUnitario' || field === 'descuento') {
|
|
|
- const cantidad = parseFloat(updatedItem.cantidad) || 0
|
|
|
- const precioUnitario = parseFloat(updatedItem.precioUnitario) || 0
|
|
|
- const descuento = parseFloat(updatedItem.descuento) || 0
|
|
|
-
|
|
|
- const subtotal = cantidad * precioUnitario
|
|
|
- const precioTotalSinImpuesto = Math.max(0, subtotal - descuento)
|
|
|
-
|
|
|
- updatedItem.precioTotalSinImpuesto = precioTotalSinImpuesto.toFixed(2)
|
|
|
- updatedItem.baseImponible = precioTotalSinImpuesto.toFixed(2)
|
|
|
-
|
|
|
- // Calcular IVA solo si aplica
|
|
|
- const tarifa = parseFloat(updatedItem.tarifa) || 0
|
|
|
- const codigoPorcentaje = updatedItem.codigoPorcentaje
|
|
|
-
|
|
|
- // No calcular IVA si es "No objeto de IVA" (código 0) o tarifa 0%
|
|
|
- if (codigoPorcentaje === '0' || tarifa === 0) {
|
|
|
- updatedItem.valorImpuesto = '0.00'
|
|
|
- } else {
|
|
|
- updatedItem.valorImpuesto = (precioTotalSinImpuesto * tarifa / 100).toFixed(2)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (field === 'tarifa' || field === 'codigoPorcentaje') {
|
|
|
- const baseImponible = parseFloat(updatedItem.baseImponible) || 0
|
|
|
- const tarifa = parseFloat(updatedItem.tarifa) || 0
|
|
|
- const codigoPorcentaje = updatedItem.codigoPorcentaje
|
|
|
-
|
|
|
- // No calcular IVA si es "No objeto de IVA" (código 0) o tarifa 0%
|
|
|
- if (codigoPorcentaje === '0' || tarifa === 0) {
|
|
|
- updatedItem.valorImpuesto = '0.00'
|
|
|
- } else {
|
|
|
- updatedItem.valorImpuesto = (baseImponible * tarifa / 100).toFixed(2)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- onChange(updatedItem)
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <Card className="p-4">
|
|
|
- <div className="flex justify-between items-center mb-4">
|
|
|
- <h4 className="text-sm font-medium">Detalle del Producto</h4>
|
|
|
- <Button
|
|
|
- type="button"
|
|
|
- variant="destructive"
|
|
|
- size="sm"
|
|
|
- onClick={onRemove}
|
|
|
- >
|
|
|
- <Trash2 className="h-4 w-4" />
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`codigo-${item.id}`}>Código Principal</Label>
|
|
|
- <Input
|
|
|
- id={`codigo-${item.id}`}
|
|
|
- value={item.codigoPrincipal}
|
|
|
- onChange={(e) => handleChange('codigoPrincipal', e.target.value)}
|
|
|
- placeholder="PROD001"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`codigo-auxiliar-${item.id}`}>Código Auxiliar</Label>
|
|
|
- <Input
|
|
|
- id={`codigo-auxiliar-${item.id}`}
|
|
|
- value={item.codigoAuxiliar}
|
|
|
- onChange={(e) => handleChange('codigoAuxiliar', e.target.value)}
|
|
|
- placeholder="AUX001"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`descripcion-${item.id}`}>Descripción</Label>
|
|
|
- <Input
|
|
|
- id={`descripcion-${item.id}`}
|
|
|
- value={item.descripcion}
|
|
|
- onChange={(e) => handleChange('descripcion', e.target.value)}
|
|
|
- placeholder="Descripción del producto"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`cantidad-${item.id}`}>Cantidad</Label>
|
|
|
- <Input
|
|
|
- id={`cantidad-${item.id}`}
|
|
|
- type="number"
|
|
|
- step="0.01"
|
|
|
- value={item.cantidad}
|
|
|
- onChange={(e) => handleChange('cantidad', e.target.value)}
|
|
|
- placeholder="1"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`precio-unitario-${item.id}`}>Precio Unitario</Label>
|
|
|
- <Input
|
|
|
- id={`precio-unitario-${item.id}`}
|
|
|
- type="number"
|
|
|
- step="0.01"
|
|
|
- value={item.precioUnitario}
|
|
|
- onChange={(e) => handleChange('precioUnitario', e.target.value)}
|
|
|
- placeholder="10.00"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`descuento-${item.id}`}>Descuento</Label>
|
|
|
- <Input
|
|
|
- id={`descuento-${item.id}`}
|
|
|
- type="number"
|
|
|
- step="0.01"
|
|
|
- value={item.descuento}
|
|
|
- onChange={(e) => handleChange('descuento', e.target.value)}
|
|
|
- placeholder="0.00"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`precio-total-${item.id}`}>Total sin Impuesto</Label>
|
|
|
- <Input
|
|
|
- id={`precio-total-${item.id}`}
|
|
|
- value={item.precioTotalSinImpuesto}
|
|
|
- readOnly
|
|
|
- className="bg-muted"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`codigo-porcentaje-${item.id}`}>Código IVA</Label>
|
|
|
- <Select value={item.codigoPorcentaje} onValueChange={(value) => handleChange('codigoPorcentaje', value)}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue placeholder="Seleccionar" />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="2">2 - IVA</SelectItem>
|
|
|
- <SelectItem value="0">0 - No objeto de IVA</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`tarifa-${item.id}`}>Tarifa IVA (%)</Label>
|
|
|
- <Select
|
|
|
- value={item.tarifa}
|
|
|
- onValueChange={(value) => handleChange('tarifa', value)}
|
|
|
- disabled={item.codigoPorcentaje === '0'}
|
|
|
- >
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue placeholder={item.codigoPorcentaje === '0' ? 'No aplica' : 'Seleccionar'} />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="12">12%</SelectItem>
|
|
|
- <SelectItem value="0">0%</SelectItem>
|
|
|
- <SelectItem value="14">14%</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- {item.codigoPorcentaje === '0' && (
|
|
|
- <p className="text-xs text-muted-foreground mt-1">
|
|
|
- No aplica
|
|
|
- </p>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor={`valor-impuesto-${item.id}`}>Valor Impuesto</Label>
|
|
|
- <Input
|
|
|
- id={`valor-impuesto-${item.id}`}
|
|
|
- value={item.valorImpuesto}
|
|
|
- readOnly
|
|
|
- className="bg-muted"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Card>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-// Funciones de validación específicas para Ecuador
|
|
|
-const validateRUC = (ruc: string): boolean => {
|
|
|
- // Validación básica de RUC ecuatoriano (13 dígitos)
|
|
|
- return /^\d{13}$/.test(ruc)
|
|
|
-}
|
|
|
-
|
|
|
-const validateCedula = (cedula: string): boolean => {
|
|
|
- // Validación básica de cédula ecuatoriana (10 dígitos)
|
|
|
- return /^\d{10}$/.test(cedula)
|
|
|
-}
|
|
|
-
|
|
|
-const validateClaveAcceso = (clave: string): boolean => {
|
|
|
- // Validación básica de clave de acceso SRI (49 dígitos)
|
|
|
- return /^\d{49}$/.test(clave)
|
|
|
-}
|
|
|
+import { FacturaHeader } from "@/components/factura/FacturaHeader"
|
|
|
+import { InfoTributariaForm } from "@/components/factura/InfoTributariaForm"
|
|
|
+import { InfoFacturaForm } from "@/components/factura/InfoFacturaForm"
|
|
|
+import { DetallesList } from "@/components/factura/DetallesList"
|
|
|
+import { ResumenTotales } from "@/components/factura/ResumenTotales"
|
|
|
+import { FacturaActions } from "@/components/factura/FacturaActions"
|
|
|
+import { XmlViewer } from "@/components/factura/XmlViewer"
|
|
|
+import { useFacturaState } from "@/hooks/factura/useFacturaState"
|
|
|
+import { useFacturaCalculations } from "@/hooks/factura/useFacturaCalculations"
|
|
|
+import { useXmlGeneration } from "@/hooks/factura/useXmlGeneration"
|
|
|
|
|
|
export default function FacturaPage() {
|
|
|
- // Estado inicial
|
|
|
- const [infoTributaria, setInfoTributaria] = useState<InfoTributaria>({
|
|
|
- ambiente: '1',
|
|
|
- tipoEmision: '1',
|
|
|
- razonSocial: '',
|
|
|
- nombreComercial: '',
|
|
|
- ruc: '',
|
|
|
- claveAcceso: '',
|
|
|
- estab: '001',
|
|
|
- ptoEmi: '001',
|
|
|
- secuencial: '000000001',
|
|
|
- dirMatriz: ''
|
|
|
- })
|
|
|
-
|
|
|
- const [infoFactura, setInfoFactura] = useState<InfoFactura>({
|
|
|
- fechaEmision: new Date().toISOString().split('T')[0],
|
|
|
- dirEstablecimiento: '',
|
|
|
- obligadoContabilidad: 'SI',
|
|
|
- tipoIdentificacionComprador: '04',
|
|
|
- razonSocialComprador: '',
|
|
|
- identificacionComprador: '',
|
|
|
- totalSinImpuestos: '0.00',
|
|
|
- totalDescuento: '0.00',
|
|
|
- importeTotal: '0.00',
|
|
|
- formaPago: '01',
|
|
|
- // Campos adicionales para infoAdicional
|
|
|
- direccionComprador: '',
|
|
|
- emailComprador: '',
|
|
|
- telefonoComprador: '',
|
|
|
- // Para pagos
|
|
|
- plazo: '0',
|
|
|
- unidadTiempo: 'dias'
|
|
|
- })
|
|
|
-
|
|
|
- const [detalles, setDetalles] = useState<DetalleItem[]>([
|
|
|
- {
|
|
|
- id: '1',
|
|
|
- codigoPrincipal: '',
|
|
|
- codigoAuxiliar: '',
|
|
|
- descripcion: '',
|
|
|
- cantidad: '1',
|
|
|
- precioUnitario: '0.00',
|
|
|
- descuento: '0.00',
|
|
|
- precioTotalSinImpuesto: '0.00',
|
|
|
- codigoPorcentaje: '2',
|
|
|
- tarifa: '12',
|
|
|
- baseImponible: '0.00',
|
|
|
- valorImpuesto: '0.00'
|
|
|
- }
|
|
|
- ])
|
|
|
-
|
|
|
- const [xmlGenerado, setXmlGenerado] = useState('')
|
|
|
-
|
|
|
- // Calcular totales automáticamente
|
|
|
- const calcularTotales = useCallback(() => {
|
|
|
- let totalSinImpuestos = 0
|
|
|
- let totalDescuento = 0
|
|
|
-
|
|
|
- detalles.forEach(item => {
|
|
|
- const subtotal = parseFloat(item.precioTotalSinImpuesto) || 0
|
|
|
- const descuento = parseFloat(item.descuento) || 0
|
|
|
-
|
|
|
- totalSinImpuestos += subtotal
|
|
|
- totalDescuento += descuento
|
|
|
- })
|
|
|
-
|
|
|
- // Calcular totales de impuestos por tipo
|
|
|
- const totalesImpuestos = new Map<string, { base: number, valor: number }>()
|
|
|
-
|
|
|
- detalles.forEach(item => {
|
|
|
- const base = parseFloat(item.baseImponible) || 0
|
|
|
- const valor = parseFloat(item.valorImpuesto) || 0
|
|
|
- const key = item.codigoPorcentaje
|
|
|
-
|
|
|
- if (totalesImpuestos.has(key)) {
|
|
|
- const current = totalesImpuestos.get(key)!
|
|
|
- totalesImpuestos.set(key, {
|
|
|
- base: current.base + base,
|
|
|
- valor: current.valor + valor
|
|
|
- })
|
|
|
- } else {
|
|
|
- totalesImpuestos.set(key, { base, valor })
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- const valorIVA = Array.from(totalesImpuestos.values()).reduce((sum, item) => sum + item.valor, 0)
|
|
|
- const importeTotal = totalSinImpuestos + valorIVA
|
|
|
-
|
|
|
- setInfoFactura(prev => ({
|
|
|
- ...prev,
|
|
|
- totalSinImpuestos: totalSinImpuestos.toFixed(2),
|
|
|
- totalDescuento: totalDescuento.toFixed(2),
|
|
|
- importeTotal: importeTotal.toFixed(2)
|
|
|
- }))
|
|
|
- }, [detalles])
|
|
|
-
|
|
|
- // Recalcular totales cuando cambian los detalles
|
|
|
- useMemo(() => {
|
|
|
- calcularTotales()
|
|
|
- }, [detalles, calcularTotales])
|
|
|
-
|
|
|
- // Manejar cambios en info tributaria
|
|
|
- const handleInfoTributariaChange = (field: keyof InfoTributaria, value: string) => {
|
|
|
- setInfoTributaria(prev => ({ ...prev, [field]: value }))
|
|
|
- }
|
|
|
-
|
|
|
- // Manejar cambios en info factura
|
|
|
- const handleInfoFacturaChange = (field: keyof InfoFactura, value: string) => {
|
|
|
- setInfoFactura(prev => ({ ...prev, [field]: value }))
|
|
|
- }
|
|
|
-
|
|
|
- // Agregar nuevo detalle
|
|
|
- const agregarDetalle = () => {
|
|
|
- const nuevoDetalle: DetalleItem = {
|
|
|
- id: Date.now().toString(),
|
|
|
- codigoPrincipal: '',
|
|
|
- codigoAuxiliar: '',
|
|
|
- descripcion: '',
|
|
|
- cantidad: '1',
|
|
|
- precioUnitario: '0.00',
|
|
|
- descuento: '0.00',
|
|
|
- precioTotalSinImpuesto: '0.00',
|
|
|
- codigoPorcentaje: '2',
|
|
|
- tarifa: '12',
|
|
|
- baseImponible: '0.00',
|
|
|
- valorImpuesto: '0.00'
|
|
|
- }
|
|
|
- setDetalles(prev => [...prev, nuevoDetalle])
|
|
|
- }
|
|
|
-
|
|
|
- // Actualizar detalle
|
|
|
- const actualizarDetalle = (id: string, updatedItem: DetalleItem) => {
|
|
|
- setDetalles(prev => prev.map(item => item.id === id ? updatedItem : item))
|
|
|
- }
|
|
|
-
|
|
|
- // Eliminar detalle
|
|
|
- const eliminarDetalle = (id: string) => {
|
|
|
- if (detalles.length > 1) {
|
|
|
- setDetalles(prev => prev.filter(item => item.id !== id))
|
|
|
- toast.success('Item eliminado')
|
|
|
- } else {
|
|
|
- toast.error('Debe haber al menos un item')
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Generar XML
|
|
|
- const generarXML = () => {
|
|
|
- // Validaciones básicas
|
|
|
- if (!validateRUC(infoTributaria.ruc)) {
|
|
|
- toast.error('RUC inválido. Debe tener 13 dígitos')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (!validateClaveAcceso(infoTributaria.claveAcceso)) {
|
|
|
- toast.error('Clave de acceso inválida. Debe tener 49 dígitos')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (!infoFactura.identificacionComprador) {
|
|
|
- toast.error('La identificación del comprador es requerida')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (infoFactura.tipoIdentificacionComprador === '04' && !validateRUC(infoFactura.identificacionComprador)) {
|
|
|
- toast.error('RUC del comprador inválido')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // Calcular totales de impuestos por tipo
|
|
|
- const totalesImpuestos = new Map<string, { base: number, valor: number }>()
|
|
|
-
|
|
|
- detalles.forEach(item => {
|
|
|
- const base = parseFloat(item.baseImponible) || 0
|
|
|
- const valor = parseFloat(item.valorImpuesto) || 0
|
|
|
- const key = item.codigoPorcentaje
|
|
|
-
|
|
|
- if (totalesImpuestos.has(key)) {
|
|
|
- const current = totalesImpuestos.get(key)!
|
|
|
- totalesImpuestos.set(key, {
|
|
|
- base: current.base + base,
|
|
|
- valor: current.valor + valor
|
|
|
- })
|
|
|
- } else {
|
|
|
- totalesImpuestos.set(key, { base, valor })
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- // Generar XML según formato real del SRI
|
|
|
- let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
-<factura id="comprobante" version="1.1.0">
|
|
|
- <infoTributaria>
|
|
|
- <ambiente>${infoTributaria.ambiente}</ambiente>
|
|
|
- <tipoEmision>${infoTributaria.tipoEmision}</tipoEmision>
|
|
|
- <razonSocial>${infoTributaria.razonSocial}</razonSocial>
|
|
|
- <nombreComercial>${infoTributaria.nombreComercial}</nombreComercial>
|
|
|
- <ruc>${infoTributaria.ruc}</ruc>
|
|
|
- <claveAcceso>${infoTributaria.claveAcceso}</claveAcceso>
|
|
|
- <codDoc>01</codDoc>
|
|
|
- <estab>${infoTributaria.estab}</estab>
|
|
|
- <ptoEmi>${infoTributaria.ptoEmi}</ptoEmi>
|
|
|
- <secuencial>${infoTributaria.secuencial}</secuencial>
|
|
|
- <dirMatriz>${infoTributaria.dirMatriz}</dirMatriz>
|
|
|
- </infoTributaria>
|
|
|
-
|
|
|
- <infoFactura>
|
|
|
- <fechaEmision>${infoFactura.fechaEmision}</fechaEmision>
|
|
|
- <dirEstablecimiento>${infoFactura.dirEstablecimiento}</dirEstablecimiento>
|
|
|
- <obligadoContabilidad>${infoFactura.obligadoContabilidad}</obligadoContabilidad>
|
|
|
- <tipoIdentificacionComprador>${infoFactura.tipoIdentificacionComprador}</tipoIdentificacionComprador>
|
|
|
- <razonSocialComprador>${infoFactura.razonSocialComprador}</razonSocialComprador>
|
|
|
- <identificacionComprador>${infoFactura.identificacionComprador}</identificacionComprador>
|
|
|
- <totalSinImpuestos>${infoFactura.totalSinImpuestos}</totalSinImpuestos>
|
|
|
- <totalDescuento>${infoFactura.totalDescuento}</totalDescuento>
|
|
|
-
|
|
|
- <totalConImpuestos>`
|
|
|
-
|
|
|
- // Agregar múltiples impuestos según los detalles
|
|
|
- totalesImpuestos.forEach((impuesto, codigoPorcentaje) => {
|
|
|
- xml += `
|
|
|
- <totalImpuesto>
|
|
|
- <codigo>2</codigo>
|
|
|
- <codigoPorcentaje>${codigoPorcentaje}</codigoPorcentaje>
|
|
|
- <baseImponible>${impuesto.base.toFixed(2)}</baseImponible>
|
|
|
- <valor>${impuesto.valor.toFixed(2)}</valor>
|
|
|
- </totalImpuesto>`
|
|
|
- })
|
|
|
-
|
|
|
- xml += `
|
|
|
- </totalConImpuestos>
|
|
|
- <propina>0.00</propina>
|
|
|
- <importeTotal>${infoFactura.importeTotal}</importeTotal>
|
|
|
- <moneda>DOLAR</moneda>
|
|
|
-
|
|
|
- <pagos>
|
|
|
- <pago>
|
|
|
- <formaPago>${infoFactura.formaPago}</formaPago>
|
|
|
- <total>${infoFactura.importeTotal}</total>
|
|
|
- <plazo>${infoFactura.plazo}</plazo>
|
|
|
- <unidadTiempo>${infoFactura.unidadTiempo}</unidadTiempo>
|
|
|
- </pago>
|
|
|
- </pagos>
|
|
|
- </infoFactura>
|
|
|
+ const {
|
|
|
+ infoTributaria,
|
|
|
+ infoFactura,
|
|
|
+ detalles,
|
|
|
+ xmlGenerado,
|
|
|
+ setXmlGenerado,
|
|
|
+ handleInfoTributariaChange,
|
|
|
+ handleInfoFacturaChange,
|
|
|
+ agregarDetalle,
|
|
|
+ actualizarDetalle,
|
|
|
+ eliminarDetalle,
|
|
|
+ updateInfoFactura
|
|
|
+ } = useFacturaState()
|
|
|
+
|
|
|
+ const { calcularTotalesImpuestos } = useFacturaCalculations(detalles, updateInfoFactura)
|
|
|
|
|
|
- <detalles>`
|
|
|
-
|
|
|
- detalles.forEach(item => {
|
|
|
- xml += `
|
|
|
- <detalle>
|
|
|
- <codigoPrincipal>${item.codigoPrincipal}</codigoPrincipal>
|
|
|
- <codigoAuxiliar>${item.codigoAuxiliar}</codigoAuxiliar>
|
|
|
- <descripcion>${item.descripcion}</descripcion>
|
|
|
- <cantidad>${item.cantidad}</cantidad>
|
|
|
- <precioUnitario>${parseFloat(item.precioUnitario).toFixed(6)}</precioUnitario>
|
|
|
- <descuento>${item.descuento}</descuento>
|
|
|
- <precioTotalSinImpuesto>${item.precioTotalSinImpuesto}</precioTotalSinImpuesto>
|
|
|
-
|
|
|
- <impuestos>
|
|
|
- <impuesto>
|
|
|
- <codigo>2</codigo>
|
|
|
- <codigoPorcentaje>${item.codigoPorcentaje}</codigoPorcentaje>
|
|
|
- <tarifa>${item.tarifa}</tarifa>
|
|
|
- <baseImponible>${item.baseImponible}</baseImponible>
|
|
|
- <valor>${item.valorImpuesto}</valor>
|
|
|
- </impuesto>
|
|
|
- </impuestos>
|
|
|
- </detalle>`
|
|
|
- })
|
|
|
-
|
|
|
- xml += `
|
|
|
- </detalles>
|
|
|
-
|
|
|
- <infoAdicional>
|
|
|
- <campoAdicional nombre="Direccion">${infoFactura.direccionComprador}</campoAdicional>
|
|
|
- <campoAdicional nombre="Email">${infoFactura.emailComprador}</campoAdicional>
|
|
|
- <campoAdicional nombre="Telefono">${infoFactura.telefonoComprador}</campoAdicional>
|
|
|
- </infoAdicional>
|
|
|
-</factura>`
|
|
|
-
|
|
|
- setXmlGenerado(xml)
|
|
|
- toast.success('XML generado exitosamente')
|
|
|
+ const { generarXml, descargarXml } = useXmlGeneration()
|
|
|
+
|
|
|
+ const handleGenerarXml = () => {
|
|
|
+ const totalesImpuestos = calcularTotalesImpuestos()
|
|
|
+ generarXml(
|
|
|
+ infoTributaria,
|
|
|
+ infoFactura,
|
|
|
+ detalles,
|
|
|
+ totalesImpuestos,
|
|
|
+ setXmlGenerado
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
- // Descargar XML
|
|
|
- const descargarXML = () => {
|
|
|
- if (!xmlGenerado) {
|
|
|
- toast.error('Debe generar el XML primero')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const blob = new Blob([xmlGenerado], { type: 'application/xml' })
|
|
|
- const url = URL.createObjectURL(blob)
|
|
|
- const a = document.createElement('a')
|
|
|
- a.href = url
|
|
|
- a.download = `factura_${infoTributaria.ruc}_${new Date().getTime()}.xml`
|
|
|
- document.body.appendChild(a)
|
|
|
- a.click()
|
|
|
- document.body.removeChild(a)
|
|
|
- URL.revokeObjectURL(url)
|
|
|
- toast.success('XML descargado')
|
|
|
+ const handleDescargarXml = () => {
|
|
|
+ descargarXml(xmlGenerado, infoTributaria.ruc)
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<div className="space-y-6">
|
|
|
{/* Header */}
|
|
|
- <div className="text-center">
|
|
|
- <h1 className="text-4xl font-bold tracking-tight">Factura Electrónica SRI Ecuador</h1>
|
|
|
- <p className="mt-2 text-muted-foreground">
|
|
|
- Generador de XML para facturación electrónica SRI
|
|
|
- </p>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Info Tributaria */}
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <CardTitle>Información Tributaria</CardTitle>
|
|
|
- <CardDescription>Datos del emisor de la factura</CardDescription>
|
|
|
- </CardHeader>
|
|
|
- <CardContent>
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="ambiente">Ambiente</Label>
|
|
|
- <Select value={infoTributaria.ambiente} onValueChange={(value) => handleInfoTributariaChange('ambiente', value)}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="1">1 - Pruebas</SelectItem>
|
|
|
- <SelectItem value="2">2 - Producción</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="tipoEmision">Tipo Emisión</Label>
|
|
|
- <Select value={infoTributaria.tipoEmision} onValueChange={(value) => handleInfoTributariaChange('tipoEmision', value)}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="1">1 - Normal</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="ruc">RUC *</Label>
|
|
|
- <Input
|
|
|
- id="ruc"
|
|
|
- value={infoTributaria.ruc}
|
|
|
- onChange={(e) => handleInfoTributariaChange('ruc', e.target.value)}
|
|
|
- placeholder="13 dígitos"
|
|
|
- maxLength={13}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="razonSocial">Razón Social *</Label>
|
|
|
- <Input
|
|
|
- id="razonSocial"
|
|
|
- value={infoTributaria.razonSocial}
|
|
|
- onChange={(e) => handleInfoTributariaChange('razonSocial', e.target.value)}
|
|
|
- placeholder="Empresa S.A."
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="nombreComercial">Nombre Comercial *</Label>
|
|
|
- <Input
|
|
|
- id="nombreComercial"
|
|
|
- value={infoTributaria.nombreComercial}
|
|
|
- onChange={(e) => handleInfoTributariaChange('nombreComercial', e.target.value)}
|
|
|
- placeholder="Nombre Comercial"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="claveAcceso">Clave de Acceso *</Label>
|
|
|
- <Input
|
|
|
- id="claveAcceso"
|
|
|
- value={infoTributaria.claveAcceso}
|
|
|
- onChange={(e) => handleInfoTributariaChange('claveAcceso', e.target.value)}
|
|
|
- placeholder="49 dígitos"
|
|
|
- maxLength={49}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="estab">Establecimiento</Label>
|
|
|
- <Input
|
|
|
- id="estab"
|
|
|
- value={infoTributaria.estab}
|
|
|
- onChange={(e) => handleInfoTributariaChange('estab', e.target.value)}
|
|
|
- placeholder="001"
|
|
|
- maxLength={3}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="ptoEmi">Punto Emisión</Label>
|
|
|
- <Input
|
|
|
- id="ptoEmi"
|
|
|
- value={infoTributaria.ptoEmi}
|
|
|
- onChange={(e) => handleInfoTributariaChange('ptoEmi', e.target.value)}
|
|
|
- placeholder="001"
|
|
|
- maxLength={3}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="secuencial">Secuencial</Label>
|
|
|
- <Input
|
|
|
- id="secuencial"
|
|
|
- value={infoTributaria.secuencial}
|
|
|
- onChange={(e) => handleInfoTributariaChange('secuencial', e.target.value)}
|
|
|
- placeholder="000000001"
|
|
|
- maxLength={9}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2 lg:col-span-3">
|
|
|
- <Label htmlFor="dirMatriz">Dirección Matriz *</Label>
|
|
|
- <Input
|
|
|
- id="dirMatriz"
|
|
|
- value={infoTributaria.dirMatriz}
|
|
|
- onChange={(e) => handleInfoTributariaChange('dirMatriz', e.target.value)}
|
|
|
- placeholder="Av. Principal 123 y Secundaria"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {/* Info Factura */}
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <CardTitle>Información de Factura</CardTitle>
|
|
|
- <CardDescription>Datos de la factura y comprador</CardDescription>
|
|
|
- </CardHeader>
|
|
|
- <CardContent>
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="fechaEmision">Fecha Emisión *</Label>
|
|
|
- <Input
|
|
|
- id="fechaEmision"
|
|
|
- type="date"
|
|
|
- value={infoFactura.fechaEmision}
|
|
|
- onChange={(e) => handleInfoFacturaChange('fechaEmision', e.target.value)}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="dirEstablecimiento">Dirección Establecimiento *</Label>
|
|
|
- <Input
|
|
|
- id="dirEstablecimiento"
|
|
|
- value={infoFactura.dirEstablecimiento}
|
|
|
- onChange={(e) => handleInfoFacturaChange('dirEstablecimiento', e.target.value)}
|
|
|
- placeholder="Av. Secundaria 456"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="telefonoComprador">Teléfono Comprador</Label>
|
|
|
- <Input
|
|
|
- id="telefonoComprador"
|
|
|
- value={infoFactura.telefonoComprador}
|
|
|
- onChange={(e) => handleInfoFacturaChange('telefonoComprador', e.target.value)}
|
|
|
- placeholder="Teléfono del comprador"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="obligadoContabilidad">Obligado Contabilidad</Label>
|
|
|
- <Select value={infoFactura.obligadoContabilidad} onValueChange={(value) => handleInfoFacturaChange('obligadoContabilidad', value)}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="SI">SI</SelectItem>
|
|
|
- <SelectItem value="NO">NO</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="tipoIdentificacionComprador">Tipo Identificación Comprador</Label>
|
|
|
- <Select value={infoFactura.tipoIdentificacionComprador} onValueChange={(value) => handleInfoFacturaChange('tipoIdentificacionComprador', value)}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="04">04 - RUC</SelectItem>
|
|
|
- <SelectItem value="05">05 - Cédula</SelectItem>
|
|
|
- <SelectItem value="06">06 - Pasaporte</SelectItem>
|
|
|
- <SelectItem value="07">07 - Consumidor Final</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="identificacionComprador">Identificación Comprador *</Label>
|
|
|
- <Input
|
|
|
- id="identificacionComprador"
|
|
|
- value={infoFactura.identificacionComprador}
|
|
|
- onChange={(e) => handleInfoFacturaChange('identificacionComprador', e.target.value)}
|
|
|
- placeholder="Según tipo seleccionado"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="razonSocialComprador">Razón Social Comprador *</Label>
|
|
|
- <Input
|
|
|
- id="razonSocialComprador"
|
|
|
- value={infoFactura.razonSocialComprador}
|
|
|
- onChange={(e) => handleInfoFacturaChange('razonSocialComprador', e.target.value)}
|
|
|
- placeholder="Nombre del comprador"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="direccionComprador">Dirección Comprador *</Label>
|
|
|
- <Input
|
|
|
- id="direccionComprador"
|
|
|
- value={infoFactura.direccionComprador}
|
|
|
- onChange={(e) => handleInfoFacturaChange('direccionComprador', e.target.value)}
|
|
|
- placeholder="Dirección del comprador"
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="formaPago">Forma de Pago</Label>
|
|
|
- <Select value={infoFactura.formaPago} onValueChange={(value) => handleInfoFacturaChange('formaPago', value)}>
|
|
|
- <SelectTrigger>
|
|
|
- <SelectValue />
|
|
|
- </SelectTrigger>
|
|
|
- <SelectContent>
|
|
|
- <SelectItem value="01">01 - Efectivo</SelectItem>
|
|
|
- <SelectItem value="15">15 - Transferencia</SelectItem>
|
|
|
- <SelectItem value="16">16 - Tarjeta Crédito</SelectItem>
|
|
|
- <SelectItem value="17">17 - Tarjeta Débito</SelectItem>
|
|
|
- <SelectItem value="20">20 - Otros</SelectItem>
|
|
|
- </SelectContent>
|
|
|
- </Select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="space-y-2">
|
|
|
- <Label htmlFor="emailComprador">Email Comprador</Label>
|
|
|
- <Input
|
|
|
- id="emailComprador"
|
|
|
- type="email"
|
|
|
- value={infoFactura.emailComprador}
|
|
|
- onChange={(e) => handleInfoFacturaChange('emailComprador', e.target.value)}
|
|
|
- placeholder="email@ejemplo.com"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {/* Detalles */}
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <div className="flex justify-between items-center">
|
|
|
- <div>
|
|
|
- <CardTitle>Detalles de Productos/Servicios</CardTitle>
|
|
|
- <CardDescription>Agregar los productos o servicios de la factura</CardDescription>
|
|
|
- </div>
|
|
|
- <Button type="button" onClick={agregarDetalle} className="flex items-center gap-2">
|
|
|
- <Plus className="h-4 w-4" />
|
|
|
- Agregar Item
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </CardHeader>
|
|
|
- <CardContent className="space-y-4">
|
|
|
- {detalles.map((item) => (
|
|
|
- <DetalleItemForm
|
|
|
- key={item.id}
|
|
|
- item={item}
|
|
|
- onChange={(updatedItem) => actualizarDetalle(item.id, updatedItem)}
|
|
|
- onRemove={() => eliminarDetalle(item.id)}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {/* Totales */}
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <CardTitle>Resumen de Totales</CardTitle>
|
|
|
- </CardHeader>
|
|
|
- <CardContent>
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
- <div className="space-y-2">
|
|
|
- <Label>Total Sin Impuestos</Label>
|
|
|
- <Input value={infoFactura.totalSinImpuestos} readOnly className="bg-muted" />
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label>Valor IVA Total</Label>
|
|
|
- <Input value={(() => {
|
|
|
- const totalIVA = detalles.reduce((sum, item) => sum + (parseFloat(item.valorImpuesto) || 0), 0)
|
|
|
- return totalIVA.toFixed(2)
|
|
|
- })()} readOnly className="bg-muted" />
|
|
|
- </div>
|
|
|
- <div className="space-y-2">
|
|
|
- <Label>Importe Total</Label>
|
|
|
- <Input value={infoFactura.importeTotal} readOnly className="bg-muted font-bold text-lg" />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
-
|
|
|
- {/* Acciones */}
|
|
|
- <div className="flex gap-4">
|
|
|
- <Button onClick={generarXML} className="flex-1">
|
|
|
- Generar XML
|
|
|
- </Button>
|
|
|
- {xmlGenerado && (
|
|
|
- <Button variant="outline" onClick={descargarXML} className="flex items-center gap-2">
|
|
|
- <Download className="h-4 w-4" />
|
|
|
- Descargar XML
|
|
|
- </Button>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* XML Generado */}
|
|
|
- {xmlGenerado && (
|
|
|
- <Card>
|
|
|
- <CardHeader>
|
|
|
- <CardTitle>XML Generado</CardTitle>
|
|
|
- <CardDescription>XML generado para el SRI</CardDescription>
|
|
|
- </CardHeader>
|
|
|
- <CardContent>
|
|
|
- <Textarea
|
|
|
- value={xmlGenerado}
|
|
|
- readOnly
|
|
|
- rows={20}
|
|
|
- className="font-mono text-sm"
|
|
|
- />
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ <FacturaHeader />
|
|
|
+
|
|
|
+ {/* Info Tributaria */}
|
|
|
+ <InfoTributariaForm
|
|
|
+ infoTributaria={infoTributaria}
|
|
|
+ onChange={handleInfoTributariaChange}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Info Factura */}
|
|
|
+ <InfoFacturaForm
|
|
|
+ infoFactura={infoFactura}
|
|
|
+ onChange={handleInfoFacturaChange}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Detalles */}
|
|
|
+ <DetallesList
|
|
|
+ detalles={detalles}
|
|
|
+ onAgregarDetalle={agregarDetalle}
|
|
|
+ onActualizarDetalle={actualizarDetalle}
|
|
|
+ onEliminarDetalle={eliminarDetalle}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Totales */}
|
|
|
+ <ResumenTotales
|
|
|
+ detalles={detalles}
|
|
|
+ importeTotal={infoFactura.importeTotal}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Acciones */}
|
|
|
+ <FacturaActions
|
|
|
+ onGenerarXml={handleGenerarXml}
|
|
|
+ onDescargarXml={handleDescargarXml}
|
|
|
+ xmlGenerado={xmlGenerado}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* XML Generado */}
|
|
|
+ <XmlViewer xmlGenerado={xmlGenerado} />
|
|
|
+ </div>
|
|
|
)
|
|
|
}
|