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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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) : (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user