# 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`: ```typescript 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 | 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 { const { data } = await apiClient.get(`/contribuyentes/${id}`); return data; } export async function createContribuyente(payload: CreateContribuyenteData): Promise { const { data } = await apiClient.post('/contribuyentes', payload); return data; } export async function updateContribuyente(id: string, payload: Partial): Promise { const { data } = await apiClient.put(`/contribuyentes/${id}`, payload); return data; } export async function deactivateContribuyente(id: string): Promise { await apiClient.delete(`/contribuyentes/${id}`); } ``` - [ ] **Step 2: Create React Query hooks** Create `apps/web/lib/hooks/use-contribuyentes.ts`: ```typescript '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.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** ```bash 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`: ```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) => { 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 (
Registra tu Despacho

30 días de prueba gratis. Sin tarjeta de crédito.

{/* Datos del despacho */}

Datos del despacho

{/* Datos del owner */}

Tu cuenta (dueño)

{/* Terms */}
setForm((prev) => ({ ...prev, acceptedTerms: e.target.checked }))} className="mt-1" />
{error && (

{error}

)}

¿Ya tienes cuenta?{' '} Inicia sesión

); } ``` - [ ] **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** ```bash 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`: ```typescript 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()( 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`: ```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(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 (
{open && (
{contribuyentes.map((c) => ( ))}
)}
); } ``` - [ ] **Step 3: Verify typecheck** Run: `pnpm --filter @horux/web typecheck` - [ ] **Step 4: Commit** ```bash 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`: ```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(null); const [form, setForm] = useState({ 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 (

Contribuyentes

RFCs que gestiona tu despacho

{isLoading ? (

Cargando...

) : !contribuyentes || contribuyentes.length === 0 ? (

Sin contribuyentes

Agrega el primer RFC para empezar a gestionar su contabilidad.

) : (
{contribuyentes.map((c) => (

{c.nombre}

{c.rfc}

{c.regimenFiscal && (

Régimen: {c.regimenFiscal}

)}
))}
)} {/* Create / Edit Dialog */} resetForm()}> {editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}
setForm((p) => ({ ...p, rfc: e.target.value }))} placeholder="ABC010203XY1" maxLength={13} disabled={!!editingId} />
setForm((p) => ({ ...p, razonSocial: e.target.value }))} placeholder="Empresa SA de CV" />
setForm((p) => ({ ...p, regimenFiscal: e.target.value }))} placeholder="601" maxLength={3} />
setForm((p) => ({ ...p, codigoPostal: e.target.value }))} placeholder="06600" maxLength={5} />
); } ``` - [ ] **Step 2: Verify typecheck** Run: `pnpm --filter @horux/web typecheck` - [ ] **Step 3: Commit** ```bash 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: ```tsx import { ContribuyenteSelector } from '../contribuyente-selector'; // Inside the render, above the nav items list:
``` Also add "Contribuyentes" to the navigation items array (for owners): ```typescript { 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: ```tsx 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** ```bash 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** ```bash pnpm --filter @horux/shared typecheck && pnpm --filter @horux/shared-ui typecheck && pnpm --filter @horux/web typecheck ``` - [ ] **Step 2: Verify commit history** ```bash git log --oneline -8 ``` - [ ] **Step 3: Visual smoke test (MANUAL)** Start: `pnpm dev` Test: 1. Open `http://localhost:3000/register-despacho` — verify form renders, fields work 2. Login with existing account → navigate to `/contribuyentes` — verify empty state 3. (If DB connected) Create a contribuyente → verify it appears in list 4. Check sidebar — verify ContribuyenteSelector dropdown appears 5. Navigate to `/cfdi` — verify list loads (filter not visible until contribuyentes exist)