feat(captura): módulo de captura por departamentos
Backend: tabla CapturaDepartamento, 5 departamentos, 7 desarrollos, service CRUD + importador Excel + 6 endpoints. Frontend: tabla editable inline, tabs por dept, resumen avance, paginación, filtro estatus, permisos por rol, dark mode. 746 API tests + 42 landing tests.
This commit is contained in:
@@ -6,10 +6,11 @@ import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
|
||||
import BannerModoLocal from './components/BannerModoLocal'
|
||||
import { VistaContactos } from './components/VistaContactos'
|
||||
import { ConfigPage } from './components/config/ConfigPage'
|
||||
import { CapturaPage } from './pages/CapturaPage'
|
||||
import { getToken, type UsuarioSesion } from './services/auth'
|
||||
import { getMe } from './services/api'
|
||||
|
||||
type Vista = 'home' | 'contactos' | 'configuraciones'
|
||||
type Vista = 'home' | 'contactos' | 'captura' | 'configuraciones'
|
||||
|
||||
export default function App() {
|
||||
const [vista, setVista] = useState<Vista>('home')
|
||||
@@ -53,6 +54,16 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
if (vista === 'captura') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
<CapturaPage onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
@@ -88,7 +99,13 @@ export default function App() {
|
||||
<TarjetaModulo
|
||||
key={modulo.id}
|
||||
modulo={modulo}
|
||||
onAbrir={modulo.id === 'contactos' ? () => setVista('contactos') : undefined}
|
||||
onAbrir={
|
||||
modulo.id === 'contactos'
|
||||
? () => setVista('contactos')
|
||||
: modulo.id === 'captura'
|
||||
? () => setVista('captura')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
97
apps/landing/src/components/ResumenCaptura.tsx
Normal file
97
apps/landing/src/components/ResumenCaptura.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { DEPARTAMENTO_LABELS, type ResumenDepartamento, type DepartamentoId } from '../services/captura'
|
||||
|
||||
interface Props {
|
||||
resumen: ResumenDepartamento[]
|
||||
departamentoActivo: DepartamentoId | null
|
||||
departamentosPermitidos: DepartamentoId[]
|
||||
onSeleccionar: (dept: DepartamentoId) => void
|
||||
}
|
||||
|
||||
/** Color de la barra según porcentaje */
|
||||
function colorBarra(pct: number): string {
|
||||
if (pct >= 80) return 'bg-green-500'
|
||||
if (pct >= 50) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function colorTexto(pct: number): string {
|
||||
if (pct >= 80) return 'text-green-600 dark:text-green-400'
|
||||
if (pct >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
|
||||
/** Barra lateral de resumen de avance por departamento */
|
||||
export function ResumenCaptura({
|
||||
resumen,
|
||||
departamentoActivo,
|
||||
departamentosPermitidos,
|
||||
onSeleccionar,
|
||||
}: Props) {
|
||||
/** Filtrar solo departamentos permitidos */
|
||||
const filtrado = resumen.filter((r) =>
|
||||
departamentosPermitidos.includes(r.departamento as DepartamentoId),
|
||||
)
|
||||
|
||||
if (filtrado.length === 0) {
|
||||
return (
|
||||
<aside className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Avance por departamento
|
||||
</h3>
|
||||
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin datos disponibles.</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Avance por departamento
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{filtrado.map((r) => {
|
||||
const dept = r.departamento as DepartamentoId
|
||||
const activo = dept === departamentoActivo
|
||||
const label = DEPARTAMENTO_LABELS[dept]
|
||||
return (
|
||||
<li key={dept}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSeleccionar(dept)}
|
||||
className={`w-full rounded-lg p-2 text-left transition-colors ${
|
||||
activo
|
||||
? 'bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'hover:bg-[#F8FAFC] dark:hover:bg-[#334155]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
activo
|
||||
? 'text-accent'
|
||||
: 'text-[#111827] dark:text-[#E2E8F0]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold ${colorTexto(r.porcentaje)}`}>
|
||||
{r.porcentaje}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-[#E5E7EB] dark:bg-[#334155]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${colorBarra(r.porcentaje)}`}
|
||||
style={{ width: `${r.porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{r.camposLlenos} de {r.camposTotales} campos
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
209
apps/landing/src/components/TablaCaptura.test.tsx
Normal file
209
apps/landing/src/components/TablaCaptura.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TablaCaptura } from './TablaCaptura'
|
||||
import type { CampoDef, DatosCaptura } from '../services/captura'
|
||||
|
||||
const CAMPOS_OBRA: CampoDef[] = [
|
||||
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' },
|
||||
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||
]
|
||||
|
||||
const CAMPOS_VENTAS: CampoDef[] = [
|
||||
{ campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO'] },
|
||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||
]
|
||||
|
||||
const DATOS: DatosCaptura = {
|
||||
'409-1': { certificacion_gas: 'true', comentarios_obra: 'Listo' },
|
||||
'409-2': { certificacion_gas: null, comentarios_obra: null },
|
||||
}
|
||||
|
||||
const UNIDADES = ['409-1', '409-2']
|
||||
|
||||
afterEach(() => { cleanup() })
|
||||
|
||||
describe('TablaCaptura', () => {
|
||||
it('renderiza la tabla con unidades y campos', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={DATOS}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('409-1')).toBeTruthy()
|
||||
expect(screen.getByText('409-2')).toBeTruthy()
|
||||
expect(screen.getByText('Certificación Gas')).toBeTruthy()
|
||||
expect(screen.getByText('Comentarios')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('muestra checkboxes para campos boolean', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={DATOS}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes.length).toBe(2) // 2 unidades * 1 boolean campo
|
||||
// Primera unidad tiene certificacion_gas = 'true'
|
||||
expect((checkboxes[0] as HTMLInputElement).checked).toBe(true)
|
||||
// Segunda unidad tiene null
|
||||
expect((checkboxes[1] as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('muestra select para campos select', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_VENTAS}
|
||||
datos={{}}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
expect(selects.length).toBe(2) // 2 unidades * 1 select campo
|
||||
})
|
||||
|
||||
it('muestra "sin unidades" cuando la lista está vacía', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={[]}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={{}}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/No hay unidades/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('edita una celda y muestra el botón de guardar', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={DATOS}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click en el checkbox de la segunda unidad (cambiar de unchecked a checked)
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
// Debería aparecer "Hay cambios sin guardar"
|
||||
expect(screen.getByText('Hay cambios sin guardar')).toBeTruthy()
|
||||
expect(screen.getByText('Guardar cambios')).toBeTruthy()
|
||||
expect(screen.getByText('Descartar')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('click en Guardar llama a onGuardar con los cambios dirty', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={DATOS}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Cambiar checkbox de la segunda unidad
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
// Click en Guardar
|
||||
fireEvent.click(screen.getByText('Guardar cambios'))
|
||||
|
||||
expect(onGuardar).toHaveBeenCalledTimes(1)
|
||||
const dirty = onGuardar.mock.calls[0][0]
|
||||
expect(dirty['409-2']).toEqual({ certificacion_gas: 'true' })
|
||||
})
|
||||
|
||||
it('click en Descartar elimina los cambios', () => {
|
||||
const onGuardar = vi.fn()
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={DATOS}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Cambiar checkbox
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
// Descartar
|
||||
fireEvent.click(screen.getByText('Descartar'))
|
||||
|
||||
// Ya no debería mostrar "Hay cambios sin guardar"
|
||||
expect(screen.queryByText('Hay cambios sin guardar')).toBeNull()
|
||||
})
|
||||
|
||||
it('filtra por estatus', () => {
|
||||
const onGuardar = vi.fn()
|
||||
const datosConEstatus: DatosCaptura = {
|
||||
'409-1': { estatus: 'VENDIDO' },
|
||||
'409-2': { estatus: 'DISPONIBLE' },
|
||||
}
|
||||
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={UNIDADES}
|
||||
campos={CAMPOS_VENTAS}
|
||||
datos={datosConEstatus}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
filtroEstatus="VENDIDO"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('409-1')).toBeTruthy()
|
||||
expect(screen.queryByText('409-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('paginación: muestra paginación cuando hay >50 unidades', () => {
|
||||
const onGuardar = vi.fn()
|
||||
const unidadesGrandes = Array.from({ length: 60 }, (_, i) => `U-${String(i + 1).padStart(3, '0')}`)
|
||||
|
||||
render(
|
||||
<TablaCaptura
|
||||
unidades={unidadesGrandes}
|
||||
campos={CAMPOS_OBRA}
|
||||
datos={{}}
|
||||
onGuardar={onGuardar}
|
||||
guardando={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Mostrando 1–50 de 60/)).toBeTruthy()
|
||||
expect(screen.getByText('Anterior')).toBeTruthy()
|
||||
expect(screen.getByText('Siguiente')).toBeTruthy()
|
||||
|
||||
// Navigate to page 2
|
||||
fireEvent.click(screen.getByText('Siguiente'))
|
||||
expect(screen.getByText(/Mostrando 51–60 de 60/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
296
apps/landing/src/components/TablaCaptura.tsx
Normal file
296
apps/landing/src/components/TablaCaptura.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import type { CampoDef, DatosCaptura } from '../services/captura'
|
||||
|
||||
// ─── Tipos ──────────────────────────────────────────────────────────
|
||||
|
||||
export type DirtyMap = Record<string, Record<string, string | null>>
|
||||
|
||||
interface Props {
|
||||
unidades: string[]
|
||||
campos: CampoDef[]
|
||||
datos: DatosCaptura
|
||||
onGuardar: (dirty: DirtyMap) => void
|
||||
guardando: boolean
|
||||
/** Filtro de estatus de unidad (columna estestatus en VENTAS) */
|
||||
filtroEstatus?: string
|
||||
}
|
||||
|
||||
const POR_PAGINA = 50
|
||||
|
||||
// ─── Utilidades ─────────────────────────────────────────────────────
|
||||
|
||||
/** Obtener valor original de una celda */
|
||||
function valorOriginal(datos: DatosCaptura, unidad: string, campo: string): string | null {
|
||||
return datos[unidad]?.[campo] ?? null
|
||||
}
|
||||
|
||||
// ─── Componente de celda ─────────────────────────────────────────────
|
||||
|
||||
interface CeldaProps {
|
||||
tipo: string
|
||||
valor: string | null
|
||||
options?: string[]
|
||||
onChange: (nuevo: string | null) => void
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
function CeldaEditable({ tipo, valor, options, onChange, dirty }: CeldaProps) {
|
||||
const bordeDirty = dirty ? 'ring-2 ring-accent/40' : ''
|
||||
|
||||
if (tipo === 'boolean') {
|
||||
return (
|
||||
<label className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={valor === 'true'}
|
||||
onChange={(e) => onChange(e.target.checked ? 'true' : null)}
|
||||
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20 dark:border-[#334155]"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (tipo === 'select' && options) {
|
||||
return (
|
||||
<select
|
||||
value={valor ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (tipo === 'date') {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={valor ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tipo === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={valor ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// text default
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={valor ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
className={`w-full border-0 bg-transparent px-1 py-0.5 text-xs text-[#111827] focus:outline-none dark:text-[#E2E8F0] ${bordeDirty}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tabla principal ─────────────────────────────────────────────────
|
||||
|
||||
export function TablaCaptura({
|
||||
unidades,
|
||||
campos,
|
||||
datos,
|
||||
onGuardar,
|
||||
guardando,
|
||||
filtroEstatus,
|
||||
}: Props) {
|
||||
const [dirty, setDirty] = useState<DirtyMap>({})
|
||||
const [pagina, setPagina] = useState(0)
|
||||
|
||||
/** Obtener estatus de una unidad (campo estatus en dept VENTAS, siempre disponible) */
|
||||
const estatusUnidad = useCallback(
|
||||
(unidad: string): string => {
|
||||
return datos[unidad]?.['estatus'] ?? ''
|
||||
},
|
||||
[datos],
|
||||
)
|
||||
|
||||
/** Unidades filtradas por estatus */
|
||||
const unidadesFiltradas = useMemo(() => {
|
||||
if (!filtroEstatus) return unidades
|
||||
return unidades.filter((u) => estatusUnidad(u) === filtroEstatus)
|
||||
}, [unidades, filtroEstatus, estatusUnidad])
|
||||
|
||||
/** Paginación */
|
||||
const totalPaginas = Math.max(1, Math.ceil(unidadesFiltradas.length / POR_PAGINA))
|
||||
const paginaActual = Math.min(pagina, totalPaginas - 1)
|
||||
const unidadesPagina = unidadesFiltradas.slice(
|
||||
paginaActual * POR_PAGINA,
|
||||
(paginaActual + 1) * POR_PAGINA,
|
||||
)
|
||||
|
||||
/** ¿Hay cambios pendientes? */
|
||||
const tieneDirty = useMemo(() => {
|
||||
for (const u of Object.values(dirty)) {
|
||||
if (Object.keys(u).length > 0) return true
|
||||
}
|
||||
return false
|
||||
}, [dirty])
|
||||
|
||||
/** Handler de cambio de celda */
|
||||
const handleChange = useCallback(
|
||||
(unidad: string, campo: string, nuevo: string | null) => {
|
||||
const original = valorOriginal(datos, unidad, campo)
|
||||
setDirty((prev) => {
|
||||
const unidadDirty = { ...(prev[unidad] ?? {}) }
|
||||
if (nuevo === original) {
|
||||
delete unidadDirty[campo]
|
||||
} else {
|
||||
unidadDirty[campo] = nuevo
|
||||
}
|
||||
const next = { ...prev }
|
||||
if (Object.keys(unidadDirty).length === 0) {
|
||||
delete next[unidad]
|
||||
} else {
|
||||
next[unidad] = unidadDirty
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[datos],
|
||||
)
|
||||
|
||||
/** Enviar cambios al padre */
|
||||
const handleSubmit = useCallback(() => {
|
||||
onGuardar(dirty)
|
||||
setDirty({})
|
||||
}, [dirty, onGuardar])
|
||||
|
||||
if (unidades.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
No hay unidades registradas para este desarrollo.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Barra de guardado */}
|
||||
{tieneDirty && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-amber-50 px-4 py-2 dark:bg-amber-900/20">
|
||||
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Hay cambios sin guardar
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={guardando}
|
||||
className="rounded-lg bg-accent px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Guardar cambios'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDirty({})}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-sm font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabla scrollable */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#1E293B]">
|
||||
<th className="sticky left-0 z-10 border-r border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#94A3B8]">
|
||||
Unidad
|
||||
</th>
|
||||
{campos.map((c) => (
|
||||
<th
|
||||
key={c.campo}
|
||||
className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||
title={c.label}
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unidadesPagina.map((unidad, idx) => (
|
||||
<tr
|
||||
key={unidad}
|
||||
className={`border-b border-[#E5E7EB] dark:border-[#334155] last:border-0 ${
|
||||
idx % 2 === 0
|
||||
? 'bg-white dark:bg-[#1E293B]'
|
||||
: 'bg-[#F9FAFB] dark:bg-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
<td className="sticky left-0 z-10 border-r border-[#E5E7EB] bg-inherit px-3 py-2 text-xs font-semibold text-[#111827] dark:border-[#334155] dark:text-[#E2E8F0]">
|
||||
{unidad}
|
||||
</td>
|
||||
{campos.map((c) => {
|
||||
const val = valorOriginal(datos, unidad, c.campo)
|
||||
const isDirty = dirty[unidad]?.[c.campo] !== undefined
|
||||
return (
|
||||
<td
|
||||
key={`${unidad}-${c.campo}`}
|
||||
className="px-1 py-0.5"
|
||||
>
|
||||
<CeldaEditable
|
||||
tipo={c.tipo}
|
||||
valor={val}
|
||||
options={c.options}
|
||||
onChange={(nuevo) => handleChange(unidad, c.campo, nuevo)}
|
||||
dirty={isDirty}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Paginación */}
|
||||
{totalPaginas > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
<span>
|
||||
Mostrando {paginaActual * POR_PAGINA + 1}–
|
||||
{Math.min((paginaActual + 1) * POR_PAGINA, unidadesFiltradas.length)} de{' '}
|
||||
{unidadesFiltradas.length} unidades
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPagina((p) => Math.max(0, p - 1))}
|
||||
disabled={paginaActual === 0}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="flex items-center px-2 text-xs">
|
||||
{paginaActual + 1} / {totalPaginas}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPagina((p) => Math.min(totalPaginas - 1, p + 1))}
|
||||
disabled={paginaActual >= totalPaginas - 1}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1 text-xs font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const INICIALES: Record<string, string> = {
|
||||
tableros: 'TB',
|
||||
'mi-dia': 'MD',
|
||||
contactos: 'CT',
|
||||
captura: 'CA',
|
||||
}
|
||||
|
||||
/** Tarjeta de acceso a un módulo del ecosistema */
|
||||
|
||||
@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
|
||||
}
|
||||
|
||||
describe('MODULOS', () => {
|
||||
it('expone los 7 módulos del ecosistema', () => {
|
||||
expect(MODULOS).toHaveLength(7)
|
||||
it('expone los 8 módulos del ecosistema', () => {
|
||||
expect(MODULOS).toHaveLength(8)
|
||||
expect(MODULOS.map((m) => m.id)).toEqual([
|
||||
'cotizador',
|
||||
'alta-clientes',
|
||||
@@ -18,6 +18,7 @@ describe('MODULOS', () => {
|
||||
'tableros',
|
||||
'mi-dia',
|
||||
'contactos',
|
||||
'captura',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -39,8 +40,9 @@ describe('MODULOS', () => {
|
||||
expect(porId('mi-dia').url).toBe('/hoy/')
|
||||
})
|
||||
|
||||
it('Contactos es vista interna (sin url externa)', () => {
|
||||
it('Contactos y Captura son vistas internas (sin url externa)', () => {
|
||||
expect(porId('contactos').url).toBeUndefined()
|
||||
expect(porId('captura').url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('liga cada módulo a la matriz de roles del API', () => {
|
||||
@@ -51,6 +53,7 @@ describe('MODULOS', () => {
|
||||
expect(porId('tableros').claves).toEqual(['mi-tablero', 'tablero-comercial'])
|
||||
expect(porId('mi-dia').claves).toEqual(['mi-dia'])
|
||||
expect(porId('contactos').claves).toEqual(['contactos'])
|
||||
expect(porId('captura').claves).toEqual(['captura'])
|
||||
})
|
||||
|
||||
it('no hay ids ni URLs duplicadas', () => {
|
||||
|
||||
@@ -59,6 +59,12 @@ export const MODULOS: Modulo[] = [
|
||||
descripcion: 'Directorio de contactos del CRM: teléfono, email y estado',
|
||||
claves: ['contactos'],
|
||||
},
|
||||
{
|
||||
id: 'captura',
|
||||
nombre: 'Captura',
|
||||
descripcion: 'Captura por departamentos: Obra, Ventas, Escrituración, Finanzas y Postventa',
|
||||
claves: ['captura'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
149
apps/landing/src/pages/CapturaPage.test.tsx
Normal file
149
apps/landing/src/pages/CapturaPage.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { CapturaPage } from './CapturaPage'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
function crearStorageStub(): Storage {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||
removeItem: (k: string) => void mapa.delete(k),
|
||||
clear: () => mapa.clear(),
|
||||
key: () => null,
|
||||
get length() { return mapa.size },
|
||||
} as Storage
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
const sesionFake = {
|
||||
token: 'jwt-de-prueba',
|
||||
usuario: {
|
||||
nombre: 'Test User',
|
||||
rol: 'admin',
|
||||
asignacion: null,
|
||||
mondayUserId: null,
|
||||
modulos: ['captura'],
|
||||
},
|
||||
}
|
||||
|
||||
const UNIDADES = ['409-1', '409-2', '409-3']
|
||||
|
||||
const DATOS = {
|
||||
'409-1': {
|
||||
certificacion_gas: 'true',
|
||||
to_c4_fianza: null,
|
||||
comentarios_obra: 'Listo',
|
||||
},
|
||||
'409-2': {},
|
||||
}
|
||||
|
||||
const RESUMEN = [
|
||||
{ departamento: 'OBRA', camposTotales: 8, camposLlenos: 3, porcentaje: 13 },
|
||||
{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 8, porcentaje: 40 },
|
||||
{ departamento: 'ESCRITURACION', camposTotales: 6, camposLlenos: 6, porcentaje: 50 },
|
||||
{ departamento: 'FINANZAS', camposTotales: 7, camposLlenos: 5, porcentaje: 36 },
|
||||
{ departamento: 'POSTVENTA', camposTotales: 2, camposLlenos: 1, porcentaje: 25 },
|
||||
]
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CapturaPage', () => {
|
||||
it('renderiza el título y tabs de departamento', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
|
||||
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
|
||||
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
|
||||
render(<CapturaPage onVolver={() => {}} />)
|
||||
|
||||
expect(screen.getByText('Captura por Departamentos')).toBeTruthy()
|
||||
const nav = await screen.findByLabelText('Departamentos')
|
||||
expect(within(nav).getByText('Obra')).toBeTruthy()
|
||||
expect(within(nav).getByText('Ventas')).toBeTruthy()
|
||||
expect(within(nav).getByText('Escrituración')).toBeTruthy()
|
||||
expect(within(nav).getByText('Finanzas')).toBeTruthy()
|
||||
expect(within(nav).getByText('Postventa')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('muestra el select de desarrollo', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
|
||||
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
|
||||
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
|
||||
render(<CapturaPage onVolver={() => {}} />)
|
||||
|
||||
const select = await screen.findByLabelText('Desarrollo')
|
||||
expect(select).toBeTruthy()
|
||||
expect((select as HTMLSelectElement).value).toBe('SENDA')
|
||||
})
|
||||
|
||||
it('cambia de tab de departamento', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
|
||||
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
|
||||
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
|
||||
render(<CapturaPage onVolver={() => {}} />)
|
||||
|
||||
const nav = await screen.findByLabelText('Departamentos')
|
||||
fireEvent.click(within(nav).getByText('Ventas'))
|
||||
|
||||
// After clicking Ventas, the fetch should be called again for Ventas tab data
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/captura/unidades'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('muestra avance por departamento en la barra lateral', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/captura/unidades')) return Promise.resolve(respuestaOk(UNIDADES))
|
||||
if (url.includes('/captura/resumen')) return Promise.resolve(respuestaOk(RESUMEN))
|
||||
if (url.includes('/captura')) return Promise.resolve(respuestaOk(DATOS))
|
||||
return Promise.reject(new Error(`fetch inesperado: ${url}`))
|
||||
})
|
||||
|
||||
render(<CapturaPage onVolver={() => {}} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Avance por departamento')).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByText('13%')).toBeTruthy()
|
||||
expect(screen.getByText('3 de 8 campos')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('muestra error si falla la carga', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 500 }))
|
||||
|
||||
render(<CapturaPage onVolver={() => {}} />)
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
232
apps/landing/src/pages/CapturaPage.tsx
Normal file
232
apps/landing/src/pages/CapturaPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
DESARROLLOS,
|
||||
DEPARTAMENTOS,
|
||||
CAMPOS_POR_DEPARTAMENTO,
|
||||
DEPARTAMENTOS_POR_ROL,
|
||||
DEPARTAMENTO_LABELS,
|
||||
obtenerCaptura,
|
||||
obtenerUnidadesCaptura,
|
||||
obtenerResumenCaptura,
|
||||
guardarBulk,
|
||||
type DatosCaptura,
|
||||
type DepartamentoId,
|
||||
type ResumenDepartamento,
|
||||
} from '../services/captura'
|
||||
import { TablaCaptura, type DirtyMap } from '../components/TablaCaptura'
|
||||
import { ResumenCaptura } from '../components/ResumenCaptura'
|
||||
import { getSesion } from '../services/auth'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
}
|
||||
|
||||
export function CapturaPage({ onVolver }: Props) {
|
||||
// ─── Estado ───────────────────────────────────────────────────────
|
||||
|
||||
const [desarrollo, setDesarrollo] = useState<string>(String(DESARROLLOS[0]))
|
||||
const [departamento, setDepartamento] = useState<DepartamentoId>(DEPARTAMENTOS[0])
|
||||
const [unidades, setUnidades] = useState<string[]>([])
|
||||
const [datos, setDatos] = useState<DatosCaptura>({})
|
||||
const [resumen, setResumen] = useState<ResumenDepartamento[]>([])
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [filtroEstatus, setFiltroEstatus] = useState('')
|
||||
|
||||
// ─── Permisos ─────────────────────────────────────────────────────
|
||||
|
||||
const rol = getSesion()?.usuario.rol ?? 'admin'
|
||||
const departamentosPermitidos = useMemo(() => {
|
||||
return (DEPARTAMENTOS_POR_ROL[rol] ?? DEPARTAMENTOS_POR_ROL['admin']) as DepartamentoId[]
|
||||
}, [rol])
|
||||
|
||||
// Si el departamento activo no está permitido, cambiar al primero permitido
|
||||
useEffect(() => {
|
||||
if (departamentosPermitidos.length > 0 && !departamentosPermitidos.includes(departamento)) {
|
||||
setDepartamento(departamentosPermitidos[0])
|
||||
}
|
||||
}, [departamentosPermitidos, departamento])
|
||||
|
||||
// ─── Carga de datos ───────────────────────────────────────────────
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
try {
|
||||
const [u, d, r] = await Promise.all([
|
||||
obtenerUnidadesCaptura(desarrollo),
|
||||
obtenerCaptura(desarrollo),
|
||||
obtenerResumenCaptura(desarrollo),
|
||||
])
|
||||
setUnidades(u)
|
||||
setDatos(d)
|
||||
setResumen(r)
|
||||
} catch {
|
||||
setError('No se pudieron cargar los datos de captura.')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [desarrollo])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
// ─── Guardar cambios ──────────────────────────────────────────────
|
||||
|
||||
const handleGuardar = useCallback(
|
||||
async (dirty: DirtyMap) => {
|
||||
setGuardando(true)
|
||||
try {
|
||||
const registros: { unidad: string; campo: string; valor: string | null }[] = []
|
||||
for (const [unidad, campos] of Object.entries(dirty)) {
|
||||
for (const [campo, valor] of Object.entries(campos)) {
|
||||
registros.push({ unidad, campo, valor })
|
||||
}
|
||||
}
|
||||
await guardarBulk({ desarrollo, departamento, registros })
|
||||
toast.success('Cambios guardados')
|
||||
await cargar()
|
||||
} catch {
|
||||
toast.error('No se pudieron guardar los cambios')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
},
|
||||
[desarrollo, departamento, cargar],
|
||||
)
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────
|
||||
|
||||
const campos = CAMPOS_POR_DEPARTAMENTO[departamento] ?? []
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-12 sm:px-6 sm:py-16">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">
|
||||
Captura por Departamentos
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="mt-6 flex flex-wrap items-end gap-4">
|
||||
{/* Desarrollo */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="captura-desarrollo"
|
||||
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Desarrollo
|
||||
</label>
|
||||
<select
|
||||
id="captura-desarrollo"
|
||||
value={desarrollo}
|
||||
onChange={(e) => setDesarrollo(e.target.value)}
|
||||
className="mt-1.5 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||
>
|
||||
{DESARROLLOS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtro de estatus */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="captura-estatus"
|
||||
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Filtrar por estatus
|
||||
</label>
|
||||
<select
|
||||
id="captura-estatus"
|
||||
value={filtroEstatus}
|
||||
onChange={(e) => setFiltroEstatus(e.target.value)}
|
||||
className="mt-1.5 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="DISPONIBLE">Disponible</option>
|
||||
<option value="APARTADO">Apartado</option>
|
||||
<option value="VENDIDO">Vendido</option>
|
||||
<option value="ESCRITURADO">Escriturado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs de departamento */}
|
||||
<nav aria-label="Departamentos" className="mt-6">
|
||||
<ul className="flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
{DEPARTAMENTOS.filter((d) =>
|
||||
departamentosPermitidos.includes(d as DepartamentoId),
|
||||
).map((d) => {
|
||||
const dept = d as DepartamentoId
|
||||
const activo = dept === departamento
|
||||
return (
|
||||
<li key={d}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDepartamento(dept)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activo
|
||||
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
|
||||
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
|
||||
}`}
|
||||
>
|
||||
{DEPARTAMENTO_LABELS[dept]}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Contenido: tabla + resumen */}
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[1fr_240px]">
|
||||
{/* Tabla */}
|
||||
<section>
|
||||
{cargando ? (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando captura…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void cargar()}
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TablaCaptura
|
||||
unidades={unidades}
|
||||
campos={campos}
|
||||
datos={datos}
|
||||
onGuardar={handleGuardar}
|
||||
guardando={guardando}
|
||||
filtroEstatus={filtroEstatus}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Resumen lateral */}
|
||||
<ResumenCaptura
|
||||
resumen={resumen}
|
||||
departamentoActivo={departamento}
|
||||
departamentosPermitidos={departamentosPermitidos}
|
||||
onSeleccionar={setDepartamento}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
179
apps/landing/src/services/captura.ts
Normal file
179
apps/landing/src/services/captura.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { API_URL, conAuth, manejar401 } from './auth'
|
||||
|
||||
// ─── Tipos ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface CampoDef {
|
||||
campo: string
|
||||
tipo: string
|
||||
label: string
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export interface ResumenDepartamento {
|
||||
departamento: string
|
||||
camposTotales: number
|
||||
camposLlenos: number
|
||||
porcentaje: number
|
||||
}
|
||||
|
||||
export type DatosCaptura = Record<string, Record<string, string | null>>
|
||||
|
||||
// ─── Departamentos y campos (espejo del backend) ────────────────────
|
||||
|
||||
export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const
|
||||
export type DepartamentoId = (typeof DEPARTAMENTOS)[number]
|
||||
|
||||
export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
|
||||
OBRA: [
|
||||
{ campo: 'certificacion_gas', tipo: 'boolean', label: 'Certificación Gas' },
|
||||
{ campo: 'to_c4_fianza', tipo: 'boolean', label: 'TO y C4-Fianza' },
|
||||
{ campo: 'regimen_inscrito', tipo: 'boolean', label: 'Régimen Inscrito' },
|
||||
{ campo: 'no_oficial', tipo: 'boolean', label: 'No. Oficial' },
|
||||
{ campo: 'clave_catastral', tipo: 'boolean', label: 'Clave Catastral' },
|
||||
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo' },
|
||||
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
|
||||
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
|
||||
],
|
||||
VENTAS: [
|
||||
{ campo: 'cliente_id', tipo: 'text', label: 'Cliente ID' },
|
||||
{ campo: 'nombre_cliente', tipo: 'text', label: 'Nombre Cliente' },
|
||||
{ campo: 'ubicacion', tipo: 'text', label: 'Ubicación/Unidad' },
|
||||
{ campo: 'modelo', tipo: 'text', label: 'Modelo' },
|
||||
{ campo: 'tipo_credito', tipo: 'select', label: 'Tipo Crédito', options: ['BANCARIO', 'COFINAVIT', 'INFONAVIT', 'SANTANDER', 'HSBC', 'BBVA', 'FOVISSSTE', 'CONTADO'] },
|
||||
{ campo: 'estatus', tipo: 'select', label: 'Estatus', options: ['DISPONIBLE', 'APARTADO', 'VENDIDO', 'ESCRITURADO'] },
|
||||
{ campo: 'fecha_apartado', tipo: 'date', label: 'Fecha Apartado' },
|
||||
{ campo: 'fecha_venta', tipo: 'date', label: 'Fecha Venta' },
|
||||
{ campo: 'fecha_vencimiento', tipo: 'date', label: 'Fecha Vencimiento Contrato' },
|
||||
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
|
||||
],
|
||||
ESCRITURACION: [
|
||||
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
|
||||
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
|
||||
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
|
||||
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
|
||||
{ campo: 'fecha_pago_infonavit', tipo: 'date', label: 'Fecha Pago Infonavit' },
|
||||
{ campo: 'fecha_pago_fovissste', tipo: 'date', label: 'Fecha Pago Fovissste' },
|
||||
],
|
||||
FINANZAS: [
|
||||
{ campo: 'otros_ingresos', tipo: 'number', label: 'Otros Ingresos' },
|
||||
{ campo: 'pagado_transferencia', tipo: 'number', label: 'Pagado Transferencia' },
|
||||
{ campo: 'infonavit', tipo: 'number', label: 'Infonavit' },
|
||||
{ campo: 'bancario', tipo: 'number', label: 'Bancario' },
|
||||
{ campo: 'fovissste', tipo: 'number', label: 'Fovissste' },
|
||||
{ campo: 'credito_puente', tipo: 'number', label: 'Crédito Puente' },
|
||||
{ campo: 'saldo_favor', tipo: 'number', label: 'Saldo a Favor' },
|
||||
],
|
||||
POSTVENTA: [
|
||||
{ campo: 'entrega_sac', tipo: 'boolean', label: 'Entrega a SAC' },
|
||||
{ campo: 'comentarios_entrega', tipo: 'text', label: 'Comentarios' },
|
||||
],
|
||||
}
|
||||
|
||||
export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const
|
||||
|
||||
/** Labels legibles para cada departamento */
|
||||
export const DEPARTAMENTO_LABELS: Record<DepartamentoId, string> = {
|
||||
OBRA: 'Obra',
|
||||
VENTAS: 'Ventas',
|
||||
ESCRITURACION: 'Escrituración',
|
||||
FINANZAS: 'Finanzas',
|
||||
POSTVENTA: 'Postventa',
|
||||
}
|
||||
|
||||
// ─── Permisos por rol ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Departamentos visibles/editables por cada rol.
|
||||
* Director y admin ven todos.
|
||||
*/
|
||||
export const DEPARTAMENTOS_POR_ROL: Record<string, DepartamentoId[]> = {
|
||||
admin: [...DEPARTAMENTOS],
|
||||
director: [...DEPARTAMENTOS],
|
||||
director_ventas: [...DEPARTAMENTOS],
|
||||
vendedor: ['VENTAS'],
|
||||
escrituracion: ['ESCRITURACION'],
|
||||
cobranza: ['FINANZAS'],
|
||||
postventa: ['POSTVENTA'],
|
||||
obra: ['OBRA'],
|
||||
}
|
||||
|
||||
// ─── API calls ──────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/captura?desarrollo=X — datos agrupados por unidad */
|
||||
export async function obtenerCaptura(
|
||||
desarrollo: string,
|
||||
departamento?: string,
|
||||
): Promise<DatosCaptura> {
|
||||
const params = new URLSearchParams({ desarrollo })
|
||||
if (departamento) params.set('departamento', departamento)
|
||||
const resp = await fetch(`${API_URL}/captura?${params}`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as DatosCaptura
|
||||
}
|
||||
|
||||
/** GET /api/captura/unidades?desarrollo=X — lista de códigos de unidad */
|
||||
export async function obtenerUnidadesCaptura(desarrollo: string): Promise<string[]> {
|
||||
const params = new URLSearchParams({ desarrollo })
|
||||
const resp = await fetch(`${API_URL}/captura/unidades?${params}`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as string[]
|
||||
}
|
||||
|
||||
/** GET /api/captura/resumen?desarrollo=X — avance por departamento */
|
||||
export async function obtenerResumenCaptura(desarrollo: string): Promise<ResumenDepartamento[]> {
|
||||
const params = new URLSearchParams({ desarrollo })
|
||||
const resp = await fetch(`${API_URL}/captura/resumen?${params}`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as ResumenDepartamento[]
|
||||
}
|
||||
|
||||
/** POST /api/captura — guardar campo individual */
|
||||
export async function guardarCampo(data: {
|
||||
desarrollo: string
|
||||
unidad: string
|
||||
departamento: string
|
||||
campo: string
|
||||
valor: string | null
|
||||
}): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_URL}/captura`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
desarrollo: data.desarrollo,
|
||||
unidadCodigo: data.unidad,
|
||||
departamento: data.departamento,
|
||||
campo: data.campo,
|
||||
valor: data.valor,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
/** POST /api/captura/bulk — guardar múltiples campos */
|
||||
export async function guardarBulk(data: {
|
||||
desarrollo: string
|
||||
departamento: string
|
||||
registros: { unidad: string; campo: string; valor: string | null }[]
|
||||
}): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_URL}/captura/bulk`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
desarrollo: data.desarrollo,
|
||||
departamento: data.departamento,
|
||||
registros: data.registros,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
Reference in New Issue
Block a user