26 KiB
Plan 2C: Frontend Despachos — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Un owner de despacho puede registrarse desde el frontend, gestionar contribuyentes (agregar/editar/desactivar RFCs), y seleccionar qué contribuyente está operando para filtrar CFDIs.
Architecture: Se crean API client functions + React Query hooks para el endpoint /api/contribuyentes. Se crea una nueva página de signup para despachos que llama a POST /api/despachos/signup. Se crea un selector de contribuyente (dropdown) persistido en Zustand store. La lista de CFDIs se filtra por el contribuyente seleccionado.
Tech Stack: Next.js 14 App Router, React 18, Zustand, React Query, Tailwind, @horux/shared-ui, Zod (client-side).
Validation: pnpm --filter @horux/web typecheck — no NEW errors vs baseline. Visual smoke test en browser.
Tasks
Task 1: API client + hooks para contribuyentes
Files:
-
Create:
apps/web/lib/api/contribuyentes.ts -
Create:
apps/web/lib/hooks/use-contribuyentes.ts -
Step 1: Create API client functions
Create apps/web/lib/api/contribuyentes.ts:
import apiClient from './client';
export interface Contribuyente {
id: string;
tipo: string;
nombre: string;
identificador: string;
supervisorUserId: string | null;
active: boolean;
createdAt: string;
rfc: string;
regimenFiscal: string | null;
codigoPostal: string | null;
domicilio: Record<string, unknown> | null;
}
export interface CreateContribuyenteData {
rfc: string;
razonSocial: string;
regimenFiscal?: string;
codigoPostal?: string;
}
export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> {
const { data } = await apiClient.get('/contribuyentes');
return data;
}
export async function getContribuyente(id: string): Promise<Contribuyente> {
const { data } = await apiClient.get(`/contribuyentes/${id}`);
return data;
}
export async function createContribuyente(payload: CreateContribuyenteData): Promise<Contribuyente> {
const { data } = await apiClient.post('/contribuyentes', payload);
return data;
}
export async function updateContribuyente(id: string, payload: Partial<CreateContribuyenteData>): Promise<Contribuyente> {
const { data } = await apiClient.put(`/contribuyentes/${id}`, payload);
return data;
}
export async function deactivateContribuyente(id: string): Promise<void> {
await apiClient.delete(`/contribuyentes/${id}`);
}
- Step 2: Create React Query hooks
Create apps/web/lib/hooks/use-contribuyentes.ts:
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/auth-store';
import * as api from '@/lib/api/contribuyentes';
export function useContribuyentes() {
const user = useAuthStore((s) => s.user);
return useQuery({
queryKey: ['contribuyentes', user?.tenantId],
queryFn: () => api.getContribuyentes().then((r) => r.data),
enabled: !!user,
});
}
export function useContribuyente(id: string | null) {
const user = useAuthStore((s) => s.user);
return useQuery({
queryKey: ['contribuyente', id, user?.tenantId],
queryFn: () => api.getContribuyente(id!),
enabled: !!user && !!id,
});
}
export function useCreateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createContribuyente,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
export function useUpdateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<api.CreateContribuyenteData> }) =>
api.updateContribuyente(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
export function useDeactivateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deactivateContribuyente,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
- Step 3: Verify typecheck
Run: pnpm --filter @horux/web typecheck
- Step 4: Commit
git add apps/web/lib/api/contribuyentes.ts apps/web/lib/hooks/use-contribuyentes.ts
git commit -m "feat(web): add API client + React Query hooks for contribuyentes"
Task 2: Signup page para despachos
Files:
-
Create:
apps/web/app/(auth)/register-despacho/page.tsx -
Step 1: Create the despacho signup page
Create apps/web/app/(auth)/register-despacho/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import apiClient from '@/lib/api/client';
export default function RegisterDespachoPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
despachoNombre: '',
despachoRfc: '',
codigoPostal: '',
ownerNombre: '',
ownerEmail: '',
ownerPassword: '',
acceptedTerms: false,
});
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.acceptedTerms) {
setError('Debes aceptar los términos y condiciones');
return;
}
setLoading(true);
setError('');
try {
const { data } = await apiClient.post('/despachos/signup', {
despacho: {
nombre: form.despachoNombre,
rfc: form.despachoRfc,
codigoPostal: form.codigoPostal || undefined,
verticalProfile: 'CONTABLE',
},
owner: {
nombre: form.ownerNombre,
email: form.ownerEmail,
password: form.ownerPassword,
},
});
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
router.push('/dashboard');
} catch (err: any) {
const msg = err.response?.data?.message || 'Error al registrar el despacho';
setError(msg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Registra tu Despacho</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
30 días de prueba gratis. Sin tarjeta de crédito.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Datos del despacho */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Datos del despacho
</h3>
<div>
<Label htmlFor="despachoNombre">Razón social</Label>
<Input
id="despachoNombre"
value={form.despachoNombre}
onChange={handleChange('despachoNombre')}
placeholder="Despacho Contable SA de CV"
required
/>
</div>
<div>
<Label htmlFor="despachoRfc">RFC del despacho</Label>
<Input
id="despachoRfc"
value={form.despachoRfc}
onChange={handleChange('despachoRfc')}
placeholder="DCO010203XY1"
maxLength={13}
required
/>
</div>
<div>
<Label htmlFor="codigoPostal">Código postal</Label>
<Input
id="codigoPostal"
value={form.codigoPostal}
onChange={handleChange('codigoPostal')}
placeholder="06600"
maxLength={5}
/>
</div>
</div>
{/* Datos del owner */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Tu cuenta (dueño)
</h3>
<div>
<Label htmlFor="ownerNombre">Nombre completo</Label>
<Input
id="ownerNombre"
value={form.ownerNombre}
onChange={handleChange('ownerNombre')}
placeholder="Juan Pérez"
required
/>
</div>
<div>
<Label htmlFor="ownerEmail">Email</Label>
<Input
id="ownerEmail"
type="email"
value={form.ownerEmail}
onChange={handleChange('ownerEmail')}
placeholder="juan@despacho.com"
required
/>
</div>
<div>
<Label htmlFor="ownerPassword">Contraseña</Label>
<Input
id="ownerPassword"
type="password"
value={form.ownerPassword}
onChange={handleChange('ownerPassword')}
placeholder="Mínimo 10 caracteres"
minLength={10}
required
/>
</div>
</div>
{/* Terms */}
<div className="flex items-start gap-2">
<input
type="checkbox"
id="terms"
checked={form.acceptedTerms}
onChange={(e) => setForm((prev) => ({ ...prev, acceptedTerms: e.target.checked }))}
className="mt-1"
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
Acepto los{' '}
<Link href="/terminos" target="_blank" className="underline text-primary">
términos y condiciones
</Link>
</label>
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Registrando...' : 'Crear despacho'}
</Button>
<p className="text-center text-sm text-muted-foreground">
¿Ya tienes cuenta?{' '}
<Link href="/login" className="text-primary underline">
Inicia sesión
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
);
}
- Step 2: Verify typecheck + visual check
Run: pnpm --filter @horux/web typecheck
Then open http://localhost:3000/register-despacho in browser to verify the form renders.
- Step 3: Commit
git add apps/web/app/\(auth\)/register-despacho/page.tsx
git commit -m "feat(web): add despacho signup page at /register-despacho"
Task 3: Contribuyente selector store + component
Files:
-
Create:
apps/web/stores/contribuyente-store.ts -
Create:
apps/web/components/contribuyente-selector.tsx -
Step 1: Create Zustand store for selected contribuyente
Create apps/web/stores/contribuyente-store.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ContribuyenteState {
selectedContribuyenteId: string | null;
selectedContribuyenteRfc: string | null;
selectedContribuyenteNombre: string | null;
setSelectedContribuyente: (id: string, rfc: string, nombre: string) => void;
clearSelectedContribuyente: () => void;
}
export const useContribuyenteStore = create<ContribuyenteState>()(
persist(
(set) => ({
selectedContribuyenteId: null,
selectedContribuyenteRfc: null,
selectedContribuyenteNombre: null,
setSelectedContribuyente: (id, rfc, nombre) =>
set({ selectedContribuyenteId: id, selectedContribuyenteRfc: rfc, selectedContribuyenteNombre: nombre }),
clearSelectedContribuyente: () =>
set({ selectedContribuyenteId: null, selectedContribuyenteRfc: null, selectedContribuyenteNombre: null }),
}),
{ name: 'horux-contribuyente' }
)
);
- Step 2: Create selector component
Create apps/web/components/contribuyente-selector.tsx:
'use client';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Button } from '@horux/shared-ui';
import { ChevronDown, Building2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
export function ContribuyenteSelector() {
const { data: contribuyentes, isLoading } = useContribuyentes();
const { selectedContribuyenteId, selectedContribuyenteRfc, setSelectedContribuyente, clearSelectedContribuyente } =
useContribuyenteStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 max-w-[250px]"
>
<Building2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-xs">
{selected ? `${selected.rfc} — ${selected.nombre}` : 'Todos los RFCs'}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0" />
</Button>
{open && (
<div className="absolute top-full left-0 mt-1 w-72 bg-popover border rounded-md shadow-lg z-50 py-1">
<button
onClick={() => {
clearSelectedContribuyente();
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent ${
!selectedContribuyenteId ? 'bg-accent font-medium' : ''
}`}
>
Todos los RFCs
</button>
<div className="border-t my-1" />
{contribuyentes.map((c) => (
<button
key={c.id}
onClick={() => {
setSelectedContribuyente(c.id, c.rfc, c.nombre);
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent ${
selectedContribuyenteId === c.id ? 'bg-accent font-medium' : ''
}`}
>
<span className="font-mono text-xs">{c.rfc}</span>
<span className="ml-2 text-muted-foreground">{c.nombre}</span>
</button>
))}
</div>
)}
</div>
);
}
- Step 3: Verify typecheck
Run: pnpm --filter @horux/web typecheck
- Step 4: Commit
git add apps/web/stores/contribuyente-store.ts apps/web/components/contribuyente-selector.tsx
git commit -m "feat(web): add contribuyente selector store + dropdown component"
Task 4: Contribuyentes management page
Files:
-
Create:
apps/web/app/(dashboard)/contribuyentes/page.tsx -
Step 1: Create the contribuyentes page
Create apps/web/app/(dashboard)/contribuyentes/page.tsx:
'use client';
import { useState } from 'react';
import {
Button, Input, Label, Card, CardContent, CardHeader, CardTitle,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@horux/shared-ui';
import {
useContribuyentes,
useCreateContribuyente,
useUpdateContribuyente,
useDeactivateContribuyente,
} from '@/lib/hooks/use-contribuyentes';
import type { CreateContribuyenteData } from '@/lib/api/contribuyentes';
import { Plus, Pencil, Trash2, Building2 } from 'lucide-react';
export default function ContribuyentesPage() {
const { data: contribuyentes, isLoading } = useContribuyentes();
const createMutation = useCreateContribuyente();
const updateMutation = useUpdateContribuyente();
const deactivateMutation = useDeactivateContribuyente();
const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateContribuyenteData>({
rfc: '', razonSocial: '', regimenFiscal: '', codigoPostal: '',
});
const resetForm = () => {
setForm({ rfc: '', razonSocial: '', regimenFiscal: '', codigoPostal: '' });
setShowCreate(false);
setEditingId(null);
};
const handleCreate = async () => {
try {
await createMutation.mutateAsync(form);
resetForm();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear contribuyente');
}
};
const handleUpdate = async () => {
if (!editingId) return;
try {
await updateMutation.mutateAsync({ id: editingId, data: form });
resetForm();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al actualizar');
}
};
const handleDeactivate = async (id: string, rfc: string) => {
if (!confirm(`¿Desactivar contribuyente ${rfc}? Esta acción no se puede deshacer.`)) return;
try {
await deactivateMutation.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desactivar');
}
};
const openEdit = (c: any) => {
setForm({ rfc: c.rfc, razonSocial: c.nombre, regimenFiscal: c.regimenFiscal || '', codigoPostal: c.codigoPostal || '' });
setEditingId(c.id);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Contribuyentes</h1>
<p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p>
</div>
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
</div>
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !contribuyentes || contribuyentes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Agrega el primer RFC para empezar a gestionar su contabilidad.
</p>
<Button onClick={() => setShowCreate(true)}>Agregar primer RFC</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{contribuyentes.map((c) => (
<Card key={c.id}>
<CardContent className="flex items-center justify-between py-4 px-6">
<div>
<p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && (
<p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeactivate(c.id, c.rfc)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create / Edit Dialog */}
<Dialog open={showCreate || !!editingId} onOpenChange={() => resetForm()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>RFC</Label>
<Input
value={form.rfc}
onChange={(e) => setForm((p) => ({ ...p, rfc: e.target.value }))}
placeholder="ABC010203XY1"
maxLength={13}
disabled={!!editingId}
/>
</div>
<div>
<Label>Razón social</Label>
<Input
value={form.razonSocial}
onChange={(e) => setForm((p) => ({ ...p, razonSocial: e.target.value }))}
placeholder="Empresa SA de CV"
/>
</div>
<div>
<Label>Régimen fiscal (clave)</Label>
<Input
value={form.regimenFiscal || ''}
onChange={(e) => setForm((p) => ({ ...p, regimenFiscal: e.target.value }))}
placeholder="601"
maxLength={3}
/>
</div>
<div>
<Label>Código postal</Label>
<Input
value={form.codigoPostal || ''}
onChange={(e) => setForm((p) => ({ ...p, codigoPostal: e.target.value }))}
placeholder="06600"
maxLength={5}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button
onClick={editingId ? handleUpdate : handleCreate}
disabled={createMutation.isPending || updateMutation.isPending}
>
{editingId ? 'Guardar' : 'Agregar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
- Step 2: Verify typecheck
Run: pnpm --filter @horux/web typecheck
- Step 3: Commit
git add apps/web/app/\(dashboard\)/contribuyentes/page.tsx
git commit -m "feat(web): add contribuyentes management page at /contribuyentes"
Task 5: Wire contribuyente selector to sidebar + CFDI filter
Files:
-
Modify:
apps/web/components/layouts/sidebar.tsx(add selector + menu item) -
Modify:
apps/web/app/(dashboard)/cfdi/page.tsx(pass contribuyenteId filter) -
Step 1: Add ContribuyenteSelector to sidebar
Open apps/web/components/layouts/sidebar.tsx. Find where the navigation items are rendered. ABOVE the nav list (but below the logo/brand area), add the ContribuyenteSelector:
import { ContribuyenteSelector } from '../contribuyente-selector';
// Inside the render, above the nav items list:
<div className="px-3 py-2">
<ContribuyenteSelector />
</div>
Also add "Contribuyentes" to the navigation items array (for owners):
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
Import Building2 from lucide-react if not already imported.
- Step 2: Wire contribuyenteId to CFDI list
Open apps/web/app/(dashboard)/cfdi/page.tsx. Find where the CFDI list hook is called (likely useCfdis() from use-cfdi.ts).
Add the contribuyente filter:
import { useContribuyenteStore } from '@/stores/contribuyente-store';
// Inside the component:
const { selectedContribuyenteId } = useContribuyenteStore();
// In the useCfdis() call or the API params, add:
// contribuyenteId: selectedContribuyenteId || undefined
The exact modification depends on how useCfdis() passes params. Read the hook and the API function to see where to add the filter. If useCfdis() accepts a filters object, add contribuyenteId to it. If it's passed as query params, add &contribuyenteId=X to the URL.
Also add selectedContribuyenteId to the React Query key so data refetches when the selector changes.
- Step 3: Verify typecheck
Run: pnpm --filter @horux/web typecheck
- Step 4: Commit
git add apps/web/components/layouts/sidebar.tsx apps/web/app/\(dashboard\)/cfdi/page.tsx
git commit -m "feat(web): wire contribuyente selector to sidebar + CFDI filter"
Task 6: Validation
- Step 1: Typecheck all packages
pnpm --filter @horux/shared typecheck && pnpm --filter @horux/shared-ui typecheck && pnpm --filter @horux/web typecheck
- Step 2: Verify commit history
git log --oneline -8
- Step 3: Visual smoke test (MANUAL)
Start: pnpm dev
Test:
- Open
http://localhost:3000/register-despacho— verify form renders, fields work - Login with existing account → navigate to
/contribuyentes— verify empty state - (If DB connected) Create a contribuyente → verify it appears in list
- Check sidebar — verify ContribuyenteSelector dropdown appears
- Navigate to
/cfdi— verify list loads (filter not visible until contribuyentes exist)