Update: nueva version Horux Despachos
This commit is contained in:
789
docs/superpowers/plans/2026-04-17-plan2c-frontend-despachos.md
Normal file
789
docs/superpowers/plans/2026-04-17-plan2c-frontend-despachos.md
Normal file
@@ -0,0 +1,789 @@
|
||||
# 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<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`:
|
||||
|
||||
```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.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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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<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`:
|
||||
|
||||
```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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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:
|
||||
<div className="px-3 py-2">
|
||||
<ContribuyenteSelector />
|
||||
</div>
|
||||
```
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user