Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/configuracion/csd/page.tsx
Horux Dev a8503fd574 fix(fiel/csd): usa contribuyente seleccionado sin depender de isDespachoTenant
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.
2026-05-16 15:53:17 +00:00

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>
</>
);
}