Files
HoruxDespachos/docs/superpowers/plans/2026-04-17-plan2c-frontend-despachos.md
2026-04-27 22:09:36 -06:00

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:

  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)