Problema: isDespachoTenant(user?.tenantRfc) compara contra prefijo 'DESPACHO_' que ningun tenant real usa. Esto hacia que sat/page.tsx siempre usara el endpoint legacy a nivel tenant, ignorando el contribuyente seleccionado y mostrando datos del tenant en lugar del contribuyente. Cambios: - sat/page.tsx: elimina isDespachoTenant, usa selectedContribuyenteId directamente para determinar contribId. Muestra banner cuando no hay contribuyente seleccionado. - csd/page.tsx: agrega banner de contribuyente seleccionado y oculta la UI de CSD cuando no hay contribuyente seleccionado. - tenant-selector.tsx: limpia selectedContribuyenteId al cambiar de tenant para evitar stale state.
395 lines
15 KiB
TypeScript
395 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Header } from '@/components/layouts/header';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
|
|
import { useTimbres } from '@/lib/hooks/use-facturacion';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
|
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2 } from 'lucide-react';
|
|
|
|
function CustomizationSection() {
|
|
const queryClient = useQueryClient();
|
|
const [logoUploading, setLogoUploading] = useState(false);
|
|
const [colorSaving, setColorSaving] = useState(false);
|
|
const [color, setColor] = useState('#75A4FF');
|
|
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
const { data: customization } = useQuery({
|
|
queryKey: ['facturapi-customization'],
|
|
queryFn: () => apiClient.get('/facturacion/customization').then(r => r.data),
|
|
});
|
|
|
|
useState(() => {
|
|
if (customization?.color) setColor(`#${customization.color}`);
|
|
});
|
|
|
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Validar tipo y tamaño
|
|
if (!file.type.startsWith('image/')) {
|
|
setMsg({ type: 'error', text: 'Solo se permiten imágenes (PNG, JPG)' });
|
|
return;
|
|
}
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
setMsg({ type: 'error', text: 'El logo no debe superar 2MB' });
|
|
return;
|
|
}
|
|
|
|
setLogoUploading(true);
|
|
setMsg(null);
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
const base64 = (reader.result as string).split(',')[1];
|
|
try {
|
|
await apiClient.post('/facturacion/logo', { logo: base64 });
|
|
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
|
|
setMsg({ type: 'success', text: 'Logo subido correctamente' });
|
|
} catch {
|
|
setMsg({ type: 'error', text: 'Error al subir logo' });
|
|
} finally {
|
|
setLogoUploading(false);
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const handleColorSave = async () => {
|
|
setColorSaving(true);
|
|
setMsg(null);
|
|
try {
|
|
await apiClient.put('/facturacion/color', { color: color.replace('#', '') });
|
|
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
|
|
setMsg({ type: 'success', text: 'Color actualizado' });
|
|
} catch {
|
|
setMsg({ type: 'error', text: 'Error al actualizar color' });
|
|
} finally {
|
|
setColorSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Palette className="h-4 w-4" />
|
|
Personalización de Factura
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Logo y color que aparecerán en los PDFs de tus facturas
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Logo */}
|
|
<div className="space-y-3">
|
|
<Label className="flex items-center gap-2">
|
|
<Image className="h-4 w-4" />
|
|
Logo de la empresa
|
|
</Label>
|
|
<div className="flex items-center gap-4">
|
|
{customization?.logoUrl && (
|
|
<img
|
|
src={customization.logoUrl}
|
|
alt="Logo"
|
|
className="h-16 w-16 object-contain rounded-lg border"
|
|
/>
|
|
)}
|
|
<div className="flex-1">
|
|
<Input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/jpg"
|
|
onChange={handleLogoUpload}
|
|
disabled={logoUploading}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
PNG o JPG, máximo 2MB. Recomendado: fondo transparente, 400x400px
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Color */}
|
|
<div className="space-y-3">
|
|
<Label>Color principal</Label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="color"
|
|
value={color}
|
|
onChange={e => setColor(e.target.value)}
|
|
className="h-10 w-14 rounded cursor-pointer border"
|
|
/>
|
|
<Input
|
|
value={color}
|
|
onChange={e => setColor(e.target.value)}
|
|
placeholder="#75A4FF"
|
|
className="w-32 font-mono"
|
|
maxLength={7}
|
|
/>
|
|
<div className="h-10 flex-1 rounded-lg border" style={{ backgroundColor: color }} />
|
|
<Button onClick={handleColorSave} disabled={colorSaving} size="sm">
|
|
{colorSaving ? 'Guardando...' : 'Guardar color'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mensaje */}
|
|
{msg && (
|
|
<div className={`p-2 rounded text-sm ${msg.type === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200' : 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200'}`}>
|
|
{msg.text}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function CsdConfigPage() {
|
|
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } = useContribuyenteStore();
|
|
const { data: orgStatus, isLoading } = useQuery({
|
|
queryKey: ['facturapi-org-contrib', selectedContribuyenteId],
|
|
queryFn: () => selectedContribuyenteId
|
|
? apiClient.get(`/contribuyentes/${selectedContribuyenteId}/facturapi/status`).then(r => r.data)
|
|
: apiClient.get('/facturacion/org/status').then(r => r.data),
|
|
});
|
|
const { data: timbres } = useTimbres();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
const [cerFile, setCerFile] = useState<string>('');
|
|
const [keyFile, setKeyFile] = useState<string>('');
|
|
const [password, setPassword] = useState('');
|
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
const handleFileChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const base64 = (reader.result as string).split(',')[1];
|
|
setter(base64);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const handleCreateOrg = async () => {
|
|
try {
|
|
if (selectedContribuyenteId) {
|
|
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`);
|
|
} else {
|
|
await apiClient.post('/facturacion/org');
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
|
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
|
|
} catch (err: any) {
|
|
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' });
|
|
}
|
|
};
|
|
|
|
const handleUploadCsd = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!cerFile || !keyFile || !password) {
|
|
setMessage({ type: 'error', text: 'Todos los campos son requeridos' });
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setMessage(null);
|
|
try {
|
|
if (selectedContribuyenteId) {
|
|
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/csd`, { cerFile, keyFile, password });
|
|
} else {
|
|
await apiClient.post('/facturacion/csd', { cerFile, keyFile, password });
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
|
setMessage({ type: 'success', text: 'CSD subido correctamente. Ya puedes emitir facturas.' });
|
|
setCerFile('');
|
|
setKeyFile('');
|
|
setPassword('');
|
|
} catch (err: any) {
|
|
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al subir CSD' });
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<>
|
|
<Header title="Configuración CSD" />
|
|
<main className="p-6"><p>Cargando...</p></main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Header title="Configuración CSD" />
|
|
<main className="p-6 space-y-6">
|
|
{/* Show which contribuyente or prompt to select */}
|
|
{!selectedContribuyenteId && (
|
|
<Card className="border-amber-200 bg-amber-50 dark:bg-amber-950/20">
|
|
<CardContent className="py-4 flex items-center gap-3">
|
|
<Building2 className="h-5 w-5 text-amber-600" />
|
|
<p className="text-sm text-amber-800 dark:text-amber-300">Selecciona un contribuyente en el header para ver y configurar su CSD.</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{selectedContribuyenteId && (
|
|
<Card className="bg-primary/5 border-primary/20">
|
|
<CardContent className="py-3 px-5 flex items-center gap-2">
|
|
<Building2 className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium">CSD de: {selectedContribuyenteNombre}</span>
|
|
<span className="text-xs text-muted-foreground font-mono">{selectedContribuyenteRfc}</span>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Estado de la organización */}
|
|
{selectedContribuyenteId && <Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Shield className="h-4 w-4" />
|
|
Organización Facturapi
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Necesaria para emitir facturas electrónicas (CFDI)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!orgStatus?.configured ? (
|
|
<div className="text-center py-4 space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
No hay organización configurada para este tenant.
|
|
</p>
|
|
<Button onClick={handleCreateOrg}>Crear Organización</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">ID Organización</span>
|
|
<span className="font-mono text-xs">{orgStatus.orgId}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">CSD</span>
|
|
<span className={orgStatus.hasCsd ? 'text-green-600' : 'text-orange-600'}>
|
|
{orgStatus.hasCsd ? '✓ Configurado' : '✗ Pendiente'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>}
|
|
|
|
{/* Subir CSD */}
|
|
{selectedContribuyenteId && orgStatus?.configured && !orgStatus.hasCsd && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Upload className="h-4 w-4" />
|
|
Subir Certificado de Sello Digital (CSD)
|
|
</CardTitle>
|
|
<CardDescription>
|
|
El CSD es diferente a la FIEL. Se usa exclusivamente para timbrar facturas.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleUploadCsd} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Certificado (.cer)</Label>
|
|
<Input type="file" accept=".cer" onChange={handleFileChange(setCerFile)} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Llave privada (.key)</Label>
|
|
<Input type="file" accept=".key" onChange={handleFileChange(setKeyFile)} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Contraseña de la llave</Label>
|
|
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
|
</div>
|
|
<Button type="submit" disabled={uploading} className="w-full">
|
|
{uploading ? 'Subiendo...' : 'Subir CSD'}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* CSD ya configurado */}
|
|
{selectedContribuyenteId && orgStatus?.configured && orgStatus.hasCsd && (
|
|
<Card>
|
|
<CardContent className="pt-6 text-center space-y-2">
|
|
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mx-auto">
|
|
<Check className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
<p className="font-medium">CSD Configurado</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Tu Certificado de Sello Digital está activo. Puedes emitir facturas.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Personalización de factura */}
|
|
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection />}
|
|
|
|
{/* Timbres */}
|
|
{selectedContribuyenteId && <Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Receipt className="h-4 w-4" />
|
|
Timbres
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{timbres?.configured ? (
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Plan</span>
|
|
<span className="capitalize">{timbres.tipo}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Usados</span>
|
|
<span>{timbres.usados} / {timbres.limite}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Disponibles</span>
|
|
<span className="font-bold">{timbres.disponibles}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Vence</span>
|
|
<span>{timbres.periodoFin}</span>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2 mt-2">
|
|
<div
|
|
className="bg-primary rounded-full h-2"
|
|
style={{ width: `${Math.min(100, ((timbres.usados ?? 0) / (timbres.limite ?? 1)) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No hay suscripción de timbres configurada. Contacta al dueño de la cuenta.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>}
|
|
|
|
{/* Mensajes */}
|
|
{selectedContribuyenteId && message && (
|
|
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${
|
|
message.type === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200'
|
|
: 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200'
|
|
}`}>
|
|
{message.type === 'success' ? <Check className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|