feat(crm): fecha de alta en cards y bitacora, click en kanban abre bitacora, tab Descartados con reasignacion para admin, tareas al calendario, estatus No asistio y edicion de unidades del inventario

This commit is contained in:
2026-09-25 18:28:14 +00:00
parent 7f663208a0
commit 2400ecf6ff
15 changed files with 527 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
actualizarUnidad,
crearUnidades,
eliminarUnidad,
estatusDe,
@@ -147,6 +148,76 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
const modelos = desarrollo.modelos ?? []
// Edición de unidad existente (precios, m², ubicación, modelo y estatus)
const [editando, setEditando] = useState<UnidadDesarrollo | null>(null)
const [formEdicion, setFormEdicion] = useState({
precioLista: '',
precioAcabados: '',
supConstruccion: '',
supExcedente: '',
supEstacionamiento: '',
manzana: '',
lote: '',
nivel: '',
modeloId: '',
status: '',
})
const abrirEdicion = (unidad: UnidadDesarrollo) => {
setEditando(unidad)
setFormEdicion({
precioLista: String(unidad.precioSinAcabados ?? unidad.precioLista ?? ''),
precioAcabados: String(unidad.precioConAcabados ?? ''),
supConstruccion: String(unidad.supConstruccion ?? ''),
supExcedente: String(unidad.supExcedente ?? ''),
supEstacionamiento: String(unidad.supEstacionamiento ?? ''),
manzana: (unidad.extras?.['manzana'] as string | undefined) ?? '',
lote: (unidad.extras?.['lote'] as string | undefined) ?? '',
nivel: unidad.nivel ?? '',
modeloId: modelos.find((m) => m.nombre === unidad.modelo)?.id ?? '',
status: estatusDe(unidad) ?? '',
})
setError('')
setExito('')
}
const cambiarEdicion = (campo: keyof typeof formEdicion) => (valor: string) =>
setFormEdicion((prev) => ({ ...prev, [campo]: valor }))
const guardarEdicion = async () => {
if (!editando) return
setError('')
setExito('')
const lista = aNumero(formEdicion.precioLista)
if (lista === null) {
setError('El precio de lista es obligatorio.')
return
}
setEnviando(true)
try {
await actualizarUnidad(desarrollo.id, editando.codigo, {
precioLista: lista,
...(aNumero(formEdicion.precioAcabados) !== null ? { precioAcabados: aNumero(formEdicion.precioAcabados)! } : {}),
...(aNumero(formEdicion.supConstruccion) !== null ? { supConstruccion: aNumero(formEdicion.supConstruccion)! } : {}),
...(aNumero(formEdicion.supExcedente) !== null ? { supExcedente: aNumero(formEdicion.supExcedente)! } : {}),
...(aNumero(formEdicion.supEstacionamiento) !== null ? { supEstacionamiento: aNumero(formEdicion.supEstacionamiento)! } : {}),
manzana: formEdicion.manzana.trim(),
lote: formEdicion.lote.trim(),
nivel: formEdicion.nivel.trim(),
...(formEdicion.modeloId !== '' ? { modeloId: formEdicion.modeloId } : {}),
...(formEdicion.status !== '' ? { status: formEdicion.status } : {}),
})
setExito(`Unidad ${editando.codigo} actualizada.`)
setEditando(null)
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo actualizar la unidad.')
} finally {
setEnviando(false)
}
}
const guardarGenerador = async () => {
setError('')
setExito('')
@@ -711,6 +782,14 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
</div>
<div className="flex shrink-0 items-center gap-2">
<BadgeEstatus estatus={estatusDe(unidad)} />
<button
type="button"
onClick={() => abrirEdicion(unidad)}
aria-label={`Editar unidad ${unidad.codigo}`}
className={BOTON_SECUNDARIO}
>
Editar
</button>
<button
type="button"
onClick={() => void borrarUnidad(unidad)}
@@ -756,6 +835,113 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
</div>
)}
</section>
{/* Modal de edición de unidad */}
{editando && (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl dark:border-[#1E293B] dark:bg-[#1E293B]">
<h3 className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
Editar unidad <span className="font-mono">{editando.codigo}</span>
</h3>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio de lista *</span>
<input
type="number"
min="0"
step="1000"
value={formEdicion.precioLista}
onChange={(e) => cambiarEdicion('precioLista')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio con acabados</span>
<input
type="number"
min="0"
step="1000"
value={formEdicion.precioAcabados}
onChange={(e) => cambiarEdicion('precioAcabados')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. construcción (m²)</span>
<input
type="number"
min="0"
step="0.01"
value={formEdicion.supConstruccion}
onChange={(e) => cambiarEdicion('supConstruccion')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. excedente (m²)</span>
<input
type="number"
min="0"
step="0.01"
value={formEdicion.supExcedente}
onChange={(e) => cambiarEdicion('supExcedente')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. estacionamiento (m²)</span>
<input
type="number"
min="0"
step="0.01"
value={formEdicion.supEstacionamiento}
onChange={(e) => cambiarEdicion('supEstacionamiento')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Modelo</span>
<select value={formEdicion.modeloId} onChange={(e) => cambiarEdicion('modeloId')(e.target.value)} className={INPUT}>
<option value="">Sin modelo</option>
{modelos.map((m) => (
<option key={m.id} value={m.id}>{m.nombre}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Manzana</span>
<input value={formEdicion.manzana} onChange={(e) => cambiarEdicion('manzana')(e.target.value)} className={INPUT} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Lote</span>
<input value={formEdicion.lote} onChange={(e) => cambiarEdicion('lote')(e.target.value)} className={INPUT} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Nivel</span>
<input value={formEdicion.nivel} onChange={(e) => cambiarEdicion('nivel')(e.target.value)} className={INPUT} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Estatus</span>
<select value={formEdicion.status} onChange={(e) => cambiarEdicion('status')(e.target.value)} className={INPUT}>
<option value="">Sin cambio</option>
{ESTATUS_UNIDAD.map((estatus) => (
<option key={estatus} value={estatus}>{estatus}</option>
))}
</select>
</label>
</div>
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
<div className="mt-5 flex justify-end gap-2">
<button type="button" onClick={() => setEditando(null)} disabled={enviando} className={BOTON_SECUNDARIO}>
Cancelar
</button>
<button type="button" onClick={() => void guardarEdicion()} disabled={enviando} className={BOTON_PRIMARIO}>
{enviando ? 'Guardando…' : 'Guardar cambios'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -382,3 +382,29 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
method: 'DELETE',
})
}
/** Cambios editables de una unidad (PATCH /desarrollos/:id/unidades/:codigo) */
export interface CambiosUnidad {
manzana?: string
lote?: string
nivel?: string
modeloId?: string
precioLista?: number
precioAcabados?: number
supConstruccion?: number
supExcedente?: number
supEstacionamiento?: number
status?: string
}
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
export async function actualizarUnidad(
desarrolloId: string,
codigo: string,
cambios: CambiosUnidad,
): Promise<UnidadDesarrollo> {
return pedir<UnidadDesarrollo>(
`${rutaDesarrollo(desarrolloId)}/unidades/${encodeURIComponent(codigo)}`,
initJson('PATCH', cambios),
)
}

View File

@@ -209,6 +209,9 @@ export default function DetalleLeadModal({
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
{'creadoEn' in item && item.creadoEn ? (
<InfoCampo etiqueta="Fecha de alta" valor={formatoFechaHora(item.creadoEn).slice(0, 10)} />
) : null}
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}

View File

@@ -13,6 +13,8 @@ interface KanbanViewProps<T extends { id: string; nombre: string; estado?: strin
obtenerColumna: (item: T) => string
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
renderCard?: (item: T) => React.ReactNode
/** Click en la tarjeta: abre la bitácora/detalle del registro */
onVerItem?: (item: T) => void
}
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
@@ -21,6 +23,7 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
obtenerColumna,
onCambiarEstado,
renderCard,
onVerItem,
}: KanbanViewProps<T>) {
const [arrastrando, setArrastrando] = useState<string | null>(null)
@@ -70,7 +73,11 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
key={item.id}
draggable
onDragStart={(e) => void onDragStart(e, item.id)}
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''}`}
onClick={onVerItem ? () => onVerItem(item) : undefined}
role={onVerItem ? 'button' : undefined}
tabIndex={onVerItem ? 0 : undefined}
onKeyDown={onVerItem ? (e) => { if (e.key === 'Enter') onVerItem(item) } : undefined}
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''} ${onVerItem ? 'cursor-pointer hover:border-accent' : ''}`}
>
{renderCard ? renderCard(item) : (
<>

View File

@@ -34,6 +34,7 @@ const apartadoFake: ApartadoMiDia = {
unidadCodigo: 'D-101',
estado: 'En proceso',
fechaLimite: '2026-09-15',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}

View File

@@ -85,6 +85,7 @@ describe('urlCotizadorParaOportunidad', () => {
unidadCodigo: 'U-9',
origen: 'FACEBOOK',
updatedAt: '2026-09-04T10:00:00Z',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}
@@ -126,6 +127,7 @@ describe('urlAfluenciaParaOportunidad', () => {
unidadCodigo: '',
origen: '',
updatedAt: '2026-09-04T10:00:00Z',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}

View File

@@ -11,6 +11,8 @@ export interface LeadMiDia {
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último cambio conocido; null si no hay */
@@ -71,6 +73,8 @@ export interface OportunidadMiDia {
/** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */
origen: string
updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
fechaCita?: string
@@ -91,9 +95,31 @@ export interface ApartadoMiDia {
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Fecha de alta (ISO) */
creadoEn: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
/**
* Registro descartado/abandonado (tab "Descartados" de Mi día, solo admin):
* leads descartados, oportunidades perdidas/abandonadas y apartados
* cancelados. Reasignable a cualquier vendedor.
*/
export interface DescartadoMiDia {
id: string
nombre: string
telefono: string
/** De dónde viene: lead | oportunidad | apartado */
fuente: 'lead' | 'oportunidad' | 'apartado'
etapa: string
motivo: string
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
/** Fecha de alta (ISO) */
creadoEn: string
updatedAt: string
}
export interface ResumenMiDia {
generadoEn: string
misLeads: LeadMiDia[]
@@ -106,6 +132,8 @@ export interface ResumenMiDia {
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
misApartados: ApartadoMiDia[]
todosApartados: ApartadoMiDia[]
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
descartados: DescartadoMiDia[]
conteos: {
leadsNuevosSemana: number
actividadesAbiertas: number

View File

@@ -28,6 +28,7 @@ import {
urlWhatsApp,
type ApartadoMiDia,
type ClaveLada,
type DescartadoMiDia,
type FaseApartado,
type LeadMiDia,
type MotivoNoCompra,
@@ -199,6 +200,9 @@ function FilaOportunidad({
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
</p>
)}
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Alta: {formatoFechaCorta(op.creadoEn.slice(0, 10))}
</p>
</div>
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{op.etapa || 'Sin etapa'}
@@ -266,7 +270,16 @@ function FilaOportunidad({
>
Aparto
</button>
{/* 5. Descartar */}
{/* 5. No asistió: el cliente agendó y no llegó (se reagenda desde Confirmar cita) */}
<button
type="button"
disabled={enviando}
onClick={() => onEtapa('No Asistio')}
className="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium text-amber-700 shadow-sm transition-all duration-100 ease-out hover:bg-amber-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-amber-700 dark:text-amber-400"
>
No asistió
</button>
{/* 6. Descartar */}
<button
type="button"
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
@@ -416,6 +429,9 @@ function FilaApartado({
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
</p>
)}
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Alta: {formatoFechaCorta(apartado.creadoEn.slice(0, 10))}
</p>
</div>
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{apartado.estado || 'Sin estado'}
@@ -668,8 +684,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Escrituración / Descartados (admin) / Mi tablero / Calendario */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'descartados' | 'tablero' | 'calendario'
function TabsCrm({
activa,
@@ -677,18 +693,22 @@ function TabsCrm({
onCambiar,
vistaKanban,
onToggleKanban,
esAdmin,
}: {
activa: TabCrm
conteos: Record<TabCrm, number>
onCambiar: (tab: TabCrm) => void
vistaKanban: boolean
onToggleKanban: () => void
/** Solo admin ve la pestaña "Descartados" */
esAdmin: boolean
}) {
const tabs: { id: TabCrm; titulo: string }[] = [
{ id: 'leads', titulo: 'Leads' },
{ id: 'oportunidades', titulo: 'Oportunidades' },
{ id: 'apartados', titulo: 'Apartados' },
{ id: 'escrituracion', titulo: 'Escrituración' },
...(esAdmin ? [{ id: 'descartados' as TabCrm, titulo: 'Descartados' }] : []),
{ id: 'tablero', titulo: 'Tablero' },
{ id: 'calendario', titulo: 'Calendario' },
]
@@ -716,7 +736,7 @@ function TabsCrm({
</button>
)
})}
{activa !== 'tablero' && (
{activa !== 'tablero' && activa !== 'descartados' && activa !== 'calendario' && (
<div className="ml-auto flex gap-1">
<button
type="button"
@@ -794,6 +814,9 @@ function FilaLead({
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
</p>
)}
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Alta: {formatoFechaCorta(lead.creadoEn.slice(0, 10))}
</p>
</div>
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{lead.estado || 'Sin estado'}
@@ -878,6 +901,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
oportunidades: FILTROS_VACIOS,
apartados: FILTROS_VACIOS,
escrituracion: FILTROS_VACIOS,
descartados: FILTROS_VACIOS,
tablero: FILTROS_VACIOS,
calendario: FILTROS_VACIOS,
})
@@ -1109,6 +1133,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
}, 'No se pudo reasignar el lead')
}
/** Reasignar un descartado/abandonado a otro vendedor (tab Descartados, admin) */
async function reasignarDescartado(item: DescartadoMiDia, usuarioId: string) {
await ejecutar(async () => {
const nombre = await reasignarLeadApi(item.id, usuarioId)
toast.success(`Reasignado a ${nombre || 'vendedor'}`)
}, 'No se pudo reasignar el registro')
}
/** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */
async function mover(lead: LeadMiDia) {
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
@@ -1366,15 +1398,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
conteos={{
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
oportunidades: resumen.misOportunidades.items.length,
apartados: resumen.misApartados.length, escrituracion: escrituracionFiltrados.length, tablero: 0, calendario: 0,
apartados: resumen.misApartados.length, escrituracion: escrituracionFiltrados.length, descartados: resumen.descartados?.length ?? 0, tablero: 0, calendario: 0,
}}
onCambiar={setTabCrm}
vistaKanban={vistaKanban}
onToggleKanban={() => setVistaKanban(!vistaKanban)}
esAdmin={esAdmin}
/>
{tabCrm !== 'tablero' && (
{tabCrm !== 'tablero' && tabCrm !== 'descartados' && tabCrm !== 'calendario' && (
<div className="flex items-center gap-2">
<div className="flex-1">
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
@@ -1416,6 +1448,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={leadsFiltrados}
columnas={columnasLeads}
obtenerColumna={obtenerColumnaLead}
onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'DESCARTADO') {
const lead = resumen.misLeads.find((l) => l.id === id)
@@ -1458,6 +1491,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={opsFiltradas}
columnas={columnasOportunidades}
obtenerColumna={obtenerColumnaOp}
onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'PERDIDO') {
const op = resumen.misOportunidades.items.find((o) => o.id === id)
@@ -1512,6 +1546,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={apartadosFiltrados}
columnas={columnasApartados}
obtenerColumna={obtenerColumnaApartado}
onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
@@ -1569,6 +1604,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={escrituracionFiltrados}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'Cancelo') {
const a = escrituracionFiltrados.find((x) => x.id === id)
@@ -1591,7 +1627,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
{apartado.nombre}
</button>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado} · Alta:{' '}
{formatoFechaCorta(apartado.creadoEn.slice(0, 10))}
</p>
</div>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
@@ -1624,6 +1663,60 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</section>
)}
{tabCrm === 'descartados' && (
<section aria-label="Descartados">
<TituloSeccion>Descartados y abandonados</TituloSeccion>
<p className="mb-4 text-sm text-[#64748B] dark:text-[#94A3B8]">
Leads, oportunidades y apartados descartados o abandonados. Puedes reasignarlos a cualquier vendedor.
</p>
{(resumen.descartados ?? []).length === 0 ? (
<EstadoVacio mensaje="No hay registros descartados" />
) : (
<ul className="space-y-2">
{(resumen.descartados ?? []).map((d) => (
<li key={d.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{d.nombre}
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-[#334155] dark:text-[#CBD5E1]">
{d.fuente === 'lead' ? 'Lead' : d.fuente === 'oportunidad' ? 'Oportunidad' : 'Apartado'}
</span>
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{d.telefono || 'Sin teléfono'}
{d.etapa ? ` · ${d.etapa}` : ''}
{d.motivo ? ` · Motivo: ${d.motivo}` : ''}
{d.vendedor ? ` · ${d.vendedor}` : ''}
</p>
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Alta: {formatoFechaCorta(d.creadoEn.slice(0, 10))} · Último cambio:{' '}
{formatoFechaCorta(d.updatedAt.slice(0, 10))}
</p>
</div>
<select
value=""
disabled={enviando}
onChange={(e) => {
if (e.target.value !== '') void reasignarDescartado(d, e.target.value)
e.target.value = ''
}}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#374151] transition-colors focus:border-accent focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
aria-label={`Reasignar ${d.nombre} a vendedor`}
>
<option value="">Reasignar a…</option>
{(resumen.equipo?.porVendedor ?? []).map((v) => (
<option key={v.usuarioId} value={v.usuarioId}>{v.nombre}</option>
))}
</select>
</div>
</li>
))}
</ul>
)}
</section>
)}
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}

View File

@@ -35,6 +35,7 @@ const leadFake: LeadMiDia = {
origen: 'PASE',
vendedor: 'Martin Zavala',
updatedAt: '2026-09-01T10:00:00Z',
creadoEn: '2026-09-01T09:00:00Z',
fuente: 'lead',
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
}
@@ -52,6 +53,7 @@ const opFake: OportunidadMiDia = {
unidadCodigo: 'D-101',
origen: '',
updatedAt: '2026-09-02T12:00:00Z',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
}
@@ -66,6 +68,7 @@ const apartadoFake: ApartadoMiDia = {
unidadCodigo: 'E-202',
estado: 'En proceso',
fechaLimite: '2026-09-15',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}