feat(9-mejoras): limpieza leads + admin CRUD + calendario + CRM360 FRASA
1. Leads limpiados (207 eliminados, 18 apartados conservados) 2. Round-robin desactivado (config round_robin_habilitado=false) 3. Admin puede borrar/reasignar/mover leads (DELETE/PATCH/POST) 4. Calendario: fechaCita guardada y pasada correctamente 5. Avisos Monday eliminados (BannerModoLocal, ChipPendienteSync, vinculación) 6. Contactos filtrados por usuario asignado 7. Modal bitácora: notas persisten entre fases 8. Fases oportunidades: Agendo→Afluencia→Cotizar→Aparto 9. Toast de nota eliminado en cambios de status 10. Branding: CRM360 FRASA + logo en todas las apps 11. 7 apps construidas
This commit is contained in:
60
api-gateway/scripts/limpiar-leads.js
Normal file
60
api-gateway/scripts/limpiar-leads.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Elimina todos los registros de LeadCaptura donde board = 'LEADS' o
|
||||
* board = 'OPORTUNIDADES'. Muestra conteo antes y después.
|
||||
*
|
||||
* Uso: npx tsx scripts/limpiar-leads.js
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import dotenv from 'dotenv'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// ── Conteo antes ──
|
||||
const total = await prisma.leadCaptura.count()
|
||||
const leads = await prisma.leadCaptura.count({ where: { board: 'LEADS' } })
|
||||
const oportunidades = await prisma.leadCaptura.count({ where: { board: 'OPORTUNIDADES' } })
|
||||
const otros = total - leads - oportunidades
|
||||
|
||||
console.log('── Antes de limpiar ──')
|
||||
console.log(` Total LeadCaptura: ${total}`)
|
||||
console.log(` board = LEADS: ${leads}`)
|
||||
console.log(` board = OPORTUNIDADES: ${oportunidades}`)
|
||||
console.log(` Otros (APARTADO, etc.): ${otros}`)
|
||||
|
||||
if (leads + oportunidades === 0) {
|
||||
console.log('\nNo hay leads ni oportunidades que borrar.')
|
||||
return
|
||||
}
|
||||
|
||||
// ── Eliminar leads y oportunidades ──
|
||||
const deleted = await prisma.leadCaptura.deleteMany({
|
||||
where: { board: { in: ['LEADS', 'OPORTUNIDADES'] } },
|
||||
})
|
||||
console.log(`\nEliminados: ${deleted.count} registros`)
|
||||
|
||||
// ── Conteo después ──
|
||||
const totalDespues = await prisma.leadCaptura.count()
|
||||
const leadsDespues = await prisma.leadCaptura.count({ where: { board: 'LEADS' } })
|
||||
const opDespues = await prisma.leadCaptura.count({ where: { board: 'OPORTUNIDADES' } })
|
||||
const otrosDespues = totalDespues - leadsDespues - opDespues
|
||||
|
||||
console.log('\n── Después de limpiar ──')
|
||||
console.log(` Total LeadCaptura: ${totalDespues}`)
|
||||
console.log(` board = LEADS: ${leadsDespues}`)
|
||||
console.log(` board = OPORTUNIDADES: ${opDespues}`)
|
||||
console.log(` Otros (APARTADO, etc.): ${otrosDespues}`)
|
||||
console.log('\n✅ Limpieza completada.')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -28,6 +28,7 @@ const CONFIGS_DEFAULT = [
|
||||
{ clave: 'auto_logout', valor: 'true' },
|
||||
{ clave: 'mostrar_precios_lista', valor: 'true' },
|
||||
{ clave: 'max_upload_mb', valor: '10' },
|
||||
{ clave: 'round_robin_habilitado', valor: 'false' },
|
||||
]
|
||||
|
||||
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ──────
|
||||
|
||||
@@ -13,16 +13,17 @@ import { NotFoundError } from '../utils/errors.js'
|
||||
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
|
||||
* Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape.
|
||||
*/
|
||||
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
res.json(await listarContactosLocal())
|
||||
res.json(await listarContactosLocal(usuarioId))
|
||||
return
|
||||
}
|
||||
try {
|
||||
res.json(await mondayService.getContactos())
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
res.json(await listarContactosLocal())
|
||||
res.json(await listarContactosLocal(usuarioId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -798,6 +798,81 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin CRUD de leads ────────────────────────────────────────────────
|
||||
|
||||
/** DELETE /api/leads/:itemId — eliminar lead (solo admin) */
|
||||
export async function eliminarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
// Eliminar de Monday si es item remoto y Monday está disponible
|
||||
if (row && row.mondayItemId && !esIdLocal(itemId) && saludMonday.estaDisponible()) {
|
||||
try {
|
||||
await mondayService.eliminarItem(row.mondayItemId)
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
}
|
||||
}
|
||||
// Eliminar la fila local (si existe)
|
||||
if (row) {
|
||||
await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {})
|
||||
}
|
||||
res.json({ eliminado: true, itemId })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ELIMINAR_LEAD', 'lead', itemId)
|
||||
}
|
||||
|
||||
const asignarLeadSchema = z.object({
|
||||
usuarioId: z.string().trim().min(1, 'usuarioId es obligatorio'),
|
||||
})
|
||||
|
||||
/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin) */
|
||||
export async function asignarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
const { usuarioId } = asignarLeadSchema.parse(req.body)
|
||||
const usuario = await prisma.usuario.findUnique({
|
||||
where: { id: usuarioId },
|
||||
select: { mondayUserId: true, nombre: true },
|
||||
})
|
||||
if (!usuario) throw new NotFoundError('Usuario no encontrado')
|
||||
// Actualizar LeadCaptura
|
||||
await prisma.leadCaptura.updateMany({
|
||||
where: { leadId: itemId },
|
||||
data: { usuarioId },
|
||||
})
|
||||
// En Monday: determinar si es lead u oportunidad
|
||||
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
const board: 'leads' | 'oportunidades' = row?.board === 'OPORTUNIDADES' ? 'oportunidades' : 'leads'
|
||||
if (usuario.mondayUserId != null && saludMonday.estaDisponible() && !esIdLocal(itemId)) {
|
||||
try {
|
||||
await automatizacionesService.asignarACapturista(itemId, row?.nombreLead ?? '', usuario.mondayUserId, board)
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
|
||||
}
|
||||
} else if (usuario.mondayUserId != null) {
|
||||
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
|
||||
}
|
||||
res.json({ itemId, usuarioId, nombre: usuario.nombre })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
|
||||
}
|
||||
|
||||
const moverLeadSchema = z.object({
|
||||
board: z.enum(['LEADS', 'OPORTUNIDADES']),
|
||||
})
|
||||
|
||||
/** POST /api/leads/:itemId/mover — mover lead a otro tablero (solo admin) */
|
||||
export async function moverLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
const { board } = moverLeadSchema.parse(req.body)
|
||||
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
if (!row) throw new NotFoundError('Lead no encontrado')
|
||||
await prisma.leadCaptura.update({
|
||||
where: { leadId: itemId },
|
||||
data: { board },
|
||||
})
|
||||
res.json({ itemId, board })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'MOVER_LEAD', 'lead', itemId, board)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
|
||||
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
|
||||
|
||||
@@ -58,6 +58,9 @@ import {
|
||||
buscarLeadsEndpoint,
|
||||
convertirLeadEndpoint,
|
||||
descartarLeadEndpoint,
|
||||
eliminarLeadEndpoint,
|
||||
asignarLeadEndpoint,
|
||||
moverLeadEndpoint,
|
||||
} from '../controllers/leads.controller.js'
|
||||
import {
|
||||
actualizarRolEndpoint,
|
||||
@@ -230,6 +233,10 @@ apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
|
||||
apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint)
|
||||
// Botón "Agendo" de Mis leads: convierte el lead en oportunidad (Agendo Cita)
|
||||
apiRouter.post('/leads/:itemId/convertir', convertirLeadEndpoint)
|
||||
// Admin CRUD de leads
|
||||
apiRouter.delete('/leads/:itemId', requireAdmin, eliminarLeadEndpoint)
|
||||
apiRouter.patch('/leads/:itemId/asignar', requireAdmin, asignarLeadEndpoint)
|
||||
apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
|
||||
|
||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { config } from '../config/index.js'
|
||||
import * as configService from './config.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import { actividadLocalService } from './actividad-local.service.js'
|
||||
import {
|
||||
@@ -325,6 +326,10 @@ export class AutomatizacionesService {
|
||||
* pasada a un tablero (rutas de los webhooks create_item).
|
||||
*/
|
||||
async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise<ResultadoRoundRobin> {
|
||||
// Round-robin deshabilitado vía config (default false en seed)
|
||||
if (soloItemId === undefined && configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
|
||||
return { asignados: 0, detalle: [] }
|
||||
}
|
||||
// Modo local: Monday caído → round-robin sobre tablas locales
|
||||
if (!saludMonday.estaDisponible() && soloItemId === undefined) {
|
||||
return this.runRoundRobinLocal()
|
||||
@@ -653,6 +658,10 @@ export class AutomatizacionesService {
|
||||
* Crea ActividadLocal de SEGUIMIENTO y actualiza LeadCaptura.usuarioId.
|
||||
*/
|
||||
async runRoundRobinLocal(): Promise<ResultadoRoundRobin> {
|
||||
// Round-robin deshabilitado vía config (default false en seed)
|
||||
if (configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
|
||||
return { asignados: 0, detalle: [] }
|
||||
}
|
||||
// Leads locales sin asignar
|
||||
const leadsSinAsignar = await prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
|
||||
@@ -17,11 +17,19 @@ export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' }
|
||||
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
|
||||
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
|
||||
* ficha completa), más reciente primero, máx 100 (como getContactos).
|
||||
* Filtra por usuario actual: clientes capturados por él + leads asignados a él.
|
||||
*/
|
||||
export async function listarContactosLocal(): Promise<ContactoListaLocal[]> {
|
||||
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' }, take: 100 })
|
||||
export async function listarContactosLocal(usuarioId?: string): Promise<ContactoListaLocal[]> {
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: usuarioId ? { capturadoPorId: usuarioId } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { board: { in: ['OPORTUNIDADES', 'APARTADO'] } },
|
||||
where: {
|
||||
board: { in: ['OPORTUNIDADES', 'APARTADO'] },
|
||||
...(usuarioId ? { usuarioId } : {}),
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
})
|
||||
|
||||
@@ -350,10 +350,21 @@ export class CrmLocalService {
|
||||
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
||||
const payload: Record<string, unknown> = { board: BOARD_PAYLOAD[board], label }
|
||||
if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha
|
||||
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
|
||||
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
|
||||
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
|
||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (existente?.datosJson) {
|
||||
try { datos = JSON.parse(existente.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
|
||||
}
|
||||
datos['fechaCita'] = opciones.fecha
|
||||
updateData['datosJson'] = JSON.stringify(datos)
|
||||
}
|
||||
await prisma.$transaction([
|
||||
prisma.leadCaptura.update({
|
||||
where: { leadId: itemId },
|
||||
data: { etapa: label, pendienteSync: true },
|
||||
data: updateData,
|
||||
}),
|
||||
prisma.syncPendiente.create({
|
||||
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },
|
||||
|
||||
@@ -117,6 +117,8 @@ export interface OportunidadMiDia {
|
||||
updatedAt: string
|
||||
/** Último evento del activity log del board; null si el item no tiene eventos */
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
||||
fechaCita?: string | undefined
|
||||
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
|
||||
pendienteSync?: boolean
|
||||
}
|
||||
@@ -319,6 +321,7 @@ export async function resumenMiDia(
|
||||
unidadCodigo: o.unidadCodigo,
|
||||
updatedAt: o.updatedAt,
|
||||
ultimoCambio: null,
|
||||
fechaCita: o.fechaCita,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
|
||||
@@ -681,11 +684,13 @@ export function resumenMiDiaLocal(
|
||||
.filter((c) => !esEtapaTerminal(c.etapa))
|
||||
.map((c) => {
|
||||
let unidadCodigo = ''
|
||||
let fechaCita: string | undefined
|
||||
if (c.datosJson) {
|
||||
try {
|
||||
const datos = JSON.parse(c.datosJson) as Record<string, unknown>
|
||||
if (typeof datos.unidadCodigo === 'string' && datos.unidadCodigo !== '') unidadCodigo = datos.unidadCodigo
|
||||
else if (typeof datos.desarrollo === 'string' && datos.desarrollo !== '') unidadCodigo = datos.desarrollo
|
||||
if (typeof datos.fechaCita === 'string') fechaCita = datos.fechaCita
|
||||
} catch { /* datosJson corrupto: ignorar */ }
|
||||
}
|
||||
return {
|
||||
@@ -699,6 +704,7 @@ export function resumenMiDiaLocal(
|
||||
unidadCodigo,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
||||
fechaCita,
|
||||
pendienteSync: c.pendienteSync,
|
||||
}
|
||||
})
|
||||
@@ -962,6 +968,7 @@ export function unirPendientesLocales(
|
||||
unidadCodigo: '',
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
|
||||
fechaCita: (() => { try { const d = JSON.parse(p.datosJson ?? '{}') as Record<string, unknown>; return typeof d.fechaCita === 'string' ? d.fechaCita : undefined } catch { return undefined } })(),
|
||||
pendienteSync: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface OportunidadMonday {
|
||||
califica?: string // dropdown 'Lead Califica'
|
||||
rangoSalarial?: string // text 'Rango Salarial'
|
||||
loQuierePara?: string // dropdown 'Proposito de Compra'
|
||||
fechaCita?: string | undefined // text 'Fecha' — fecha/hora de la cita agendada
|
||||
}
|
||||
|
||||
/** Usuario del workspace (para la lista rotativa de vendedores) */
|
||||
@@ -1116,9 +1117,11 @@ export class MondayService {
|
||||
califica: textoDe('Lead Califica'),
|
||||
rangoSalarial: textoDe('Rango Salarial'),
|
||||
loQuierePara: textoDe('Proposito de Compra'),
|
||||
fechaCita: textoDe('Fecha') || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que
|
||||
* ahora vive en Oportunidades). Compara solo dígitos y los últimos 10
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#1B4332" />
|
||||
<link rel="manifest" href="/ai/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/ai/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Asistente IA</title>
|
||||
<title>CRM360 FRASA — Asistente IA</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Frasa · Asistente IA",
|
||||
"name": "CRM360 FRASA · Asistente IA",
|
||||
"short_name": "Asistente IA",
|
||||
"start_url": "/ai/",
|
||||
"scope": "/ai/",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-wide">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
CRM360 FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#1B4332" />
|
||||
<link rel="manifest" href="/client/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/client/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Alta de Clientes</title>
|
||||
<title>CRM360 FRASA — Alta de Clientes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Frasa · Alta de clientes",
|
||||
"name": "CRM360 FRASA · Alta de clientes",
|
||||
"short_name": "Alta clientes",
|
||||
"start_url": "/client/",
|
||||
"scope": "/client/",
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></p>
|
||||
<p className="text-lg font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">CRM360 FRASA<span className="text-[#F97316]">.</span></p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import AppHeader from '../components/AppHeader'
|
||||
import { SkeletonCard } from "../components/Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import Stepper from '../components/Stepper'
|
||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||
import EmpleoForm from '../forms/EmpleoForm'
|
||||
@@ -349,7 +348,6 @@ export default function AltaClientePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta de Clientes" />
|
||||
<BannerModoLocal />
|
||||
<main className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#1B4332" />
|
||||
<link rel="manifest" href="/pay/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/pay/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Clientes</title>
|
||||
<title>CRM360 FRASA — Clientes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Frasa · Clientes",
|
||||
"name": "CRM360 FRASA · Clientes",
|
||||
"short_name": "Clientes",
|
||||
"start_url": "/pay/",
|
||||
"scope": "/pay/",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
CRM360 FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="manifest" href="/quote/manifest.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/quote/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Cotizador</title>
|
||||
<title>CRM360 FRASA — Cotizador</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "FRASA CRM",
|
||||
"short_name": "FRASA",
|
||||
"name": "CRM360 FRASA",
|
||||
"short_name": "CRM360",
|
||||
"start_url": "/quote/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F8FAFC",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Frasa · Cotizador",
|
||||
"name": "CRM360 FRASA · Cotizador",
|
||||
"short_name": "Cotizador",
|
||||
"start_url": "/quote/",
|
||||
"scope": "/quote/",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-lg font-bold tracking-wide">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
CRM360 FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
|
||||
@@ -5,7 +5,6 @@ import { z } from 'zod'
|
||||
import QRCode from 'qrcode'
|
||||
import { useUnidades } from '../hooks/useUnidades'
|
||||
import { useCotizacion } from '../hooks/useCotizacion'
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
@@ -307,7 +306,6 @@ export default function CotizadorPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<BannerModoLocal />
|
||||
<main className="px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
|
||||
{/* Encabezado de página */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#1B4332" />
|
||||
<link rel="manifest" href="/dash/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/dash/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Tableros</title>
|
||||
<title>CRM360 FRASA — Tableros</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Frasa · Tableros",
|
||||
"name": "CRM360 FRASA · Tableros",
|
||||
"short_name": "Tableros",
|
||||
"start_url": "/dash/",
|
||||
"scope": "/dash/",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
CRM360 FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
|
||||
@@ -4,7 +4,6 @@ import TableroGeneral from '../components/TableroGeneral'
|
||||
import type { AccionesTablero } from '../components/comunes'
|
||||
import { esRolConEquipo } from '../lib/mi-tablero'
|
||||
import { getMe, getSesion } from '../services/auth'
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
|
||||
type Vista = 'mia' | 'general'
|
||||
@@ -45,7 +44,6 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BannerModoLocal />
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="flex min-h-14 flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1B4332" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<meta name="description" content="Página de entrada al ecosistema comercial de FRASA Desarrollos" />
|
||||
<title>FRASA Desarrollos — Ecosistema comercial</title>
|
||||
<meta name="description" content="CRM360 FRASA — Ecosistema comercial" />
|
||||
<title>CRM360 FRASA — Ecosistema comercial</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
apps/landing/public/apple-touch-icon.png
Normal file
BIN
apps/landing/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/landing/public/favicon.png
Normal file
BIN
apps/landing/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/landing/public/logo.png
Normal file
BIN
apps/landing/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 478 KiB |
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "Frasa Desarrollos",
|
||||
"short_name": "Frasa",
|
||||
"name": "CRM360 FRASA",
|
||||
"short_name": "CRM360",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1B4332",
|
||||
"icons": [
|
||||
{ "src": "/favicon.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/logo.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MODULOS, esModuloVisible } from './data/modulos'
|
||||
import { Navbar } from './components/Navbar'
|
||||
import { TarjetaModulo } from './components/TarjetaModulo'
|
||||
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'
|
||||
@@ -49,7 +48,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<VistaContactos onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
@@ -59,7 +58,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<CapturaPage onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
@@ -69,7 +68,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<ClientesMaestroPage onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
@@ -79,7 +78,7 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<ConfigPage onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
@@ -88,7 +87,6 @@ export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<main className="w-full flex-1 px-4 sm:px-6 lg:px-8">
|
||||
<section aria-labelledby="titulo-hero" className="py-12 sm:py-16">
|
||||
@@ -99,7 +97,7 @@ export default function App() {
|
||||
Ecosistema comercial
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8] sm:text-base">
|
||||
Las herramientas del equipo comercial de Frasa Desarrollos en un solo lugar:
|
||||
Las herramientas del equipo comercial de CRM360 FRASA en un solo lugar:
|
||||
cotizaciones, alta de clientes, cobranza, tableros y asistente IA.
|
||||
</p>
|
||||
</section>
|
||||
@@ -131,7 +129,7 @@ export default function App() {
|
||||
|
||||
<footer className="mt-16 border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
||||
<div className="px-6 py-8 sm:px-6 lg:px-8">
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Consult (AS) Technology</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">CRM360 FRASA · Consult (AS) Technology</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ErrorLogin, getSesion, login, logout, type UsuarioSesion } from '../services/auth'
|
||||
import { getMe, getUsuariosMonday, guardarVinculoMonday, type UsuarioMonday } from '../services/api'
|
||||
import { getMe } from '../services/api'
|
||||
|
||||
const labelEstilo = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
const inputEstilo =
|
||||
@@ -27,39 +27,20 @@ export function ConfiguracionCuenta() {
|
||||
const [entrando, setEntrando] = useState(false)
|
||||
const [errorLogin, setErrorLogin] = useState('')
|
||||
|
||||
// ---------- Perfil + usuarios Monday ----------
|
||||
// ---------- Perfil ----------
|
||||
const [perfil, setPerfil] = useState<UsuarioSesion | null>(null)
|
||||
const [cargandoPerfil, setCargandoPerfil] = useState(false)
|
||||
const [usuarios, setUsuarios] = useState<UsuarioMonday[] | null>(null)
|
||||
const [errorUsuarios, setErrorUsuarios] = useState('')
|
||||
const [seleccion, setSeleccion] = useState('')
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [aviso, setAviso] = useState('')
|
||||
const [errorGuardar, setErrorGuardar] = useState('')
|
||||
const [errorPerfil, setErrorPerfil] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!conSesion) return
|
||||
setCargandoPerfil(true)
|
||||
getMe()
|
||||
.then((u) => {
|
||||
setPerfil(u)
|
||||
setSeleccion(u.mondayUserId !== null ? String(u.mondayUserId) : '')
|
||||
})
|
||||
.catch(() => setErrorGuardar('No se pudo cargar tu perfil.'))
|
||||
.then((u) => setPerfil(u))
|
||||
.catch(() => setErrorPerfil('No se pudo cargar tu perfil.'))
|
||||
.finally(() => setCargandoPerfil(false))
|
||||
cargarUsuarios()
|
||||
}, [conSesion])
|
||||
|
||||
const cargarUsuarios = () => {
|
||||
setErrorUsuarios('')
|
||||
getUsuariosMonday()
|
||||
.then(setUsuarios)
|
||||
.catch(() => {
|
||||
setUsuarios(null)
|
||||
setErrorUsuarios('No se pudo cargar la lista de usuarios de Monday.')
|
||||
})
|
||||
}
|
||||
|
||||
const entrar = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setEntrando(true)
|
||||
@@ -76,32 +57,12 @@ export function ConfiguracionCuenta() {
|
||||
}
|
||||
}
|
||||
|
||||
const guardar = async (mondayUserId: number | null) => {
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setErrorGuardar('')
|
||||
try {
|
||||
const actualizado = await guardarVinculoMonday(mondayUserId)
|
||||
setPerfil(actualizado)
|
||||
setSeleccion(actualizado.mondayUserId !== null ? String(actualizado.mondayUserId) : '')
|
||||
setAviso(mondayUserId === null ? 'Vínculo con Monday eliminado' : 'Vínculo con Monday guardado')
|
||||
} catch {
|
||||
setErrorGuardar('No se pudo guardar el vínculo. Intenta de nuevo.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cerrarSesion = () => {
|
||||
logout()
|
||||
setPerfil(null)
|
||||
setUsuarios(null)
|
||||
setConSesion(false)
|
||||
}
|
||||
|
||||
const vinculado =
|
||||
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
|
||||
|
||||
return (
|
||||
<section
|
||||
id="configuracion"
|
||||
@@ -118,7 +79,7 @@ export function ConfiguracionCuenta() {
|
||||
className="mt-6 max-w-sm"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
||||
Inicia sesión para vincular tu usuario de Monday.
|
||||
Inicia sesión para acceder a las herramientas del ecosistema.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<label htmlFor="cuenta-username" className={labelEstilo}>
|
||||
@@ -171,83 +132,10 @@ export function ConfiguracionCuenta() {
|
||||
Sesión: <span className="font-medium">{perfil.nombre}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Rol: {formatearRol(perfil.rol)}</p>
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Vínculo Monday actual:{' '}
|
||||
{perfil.mondayUserId === null ? (
|
||||
<span className="text-[#94A3B8] dark:text-[#64748B]">sin vincular</span>
|
||||
) : vinculado ? (
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">
|
||||
{vinculado.nombre} ({vinculado.email})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">ID {perfil.mondayUserId}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{errorUsuarios ? (
|
||||
<div className="mt-6">
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{errorUsuarios}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargarUsuarios}
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : usuarios === null ? (
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando usuarios de Monday…</p>
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
<label htmlFor="cuenta-monday" className={labelEstilo}>
|
||||
Tu usuario de Monday
|
||||
</label>
|
||||
<select
|
||||
id="cuenta-monday"
|
||||
value={seleccion}
|
||||
onChange={(e) => setSeleccion(e.target.value)}
|
||||
className={inputEstilo}
|
||||
>
|
||||
<option value="">— Sin vincular —</option>
|
||||
{usuarios.map((u) => (
|
||||
<option key={u.id} value={String(u.id)}>
|
||||
{u.nombre} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={guardando}
|
||||
onClick={() => void guardar(seleccion === '' ? null : Number(seleccion))}
|
||||
className="rounded-lg bg-accent px-5 py-2.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'}
|
||||
</button>
|
||||
{perfil.mondayUserId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={guardando}
|
||||
onClick={() => void guardar(null)}
|
||||
className="rounded-lg border border-red-300 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
Desvincular
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aviso && (
|
||||
<p role="status" className="mt-4 text-sm font-medium text-green-600">
|
||||
{aviso}
|
||||
</p>
|
||||
)}
|
||||
{errorGuardar && (
|
||||
{errorPerfil && (
|
||||
<p role="alert" className="mt-4 text-sm text-red-600">
|
||||
{errorGuardar}
|
||||
{errorPerfil}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="flex h-14 items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<span className="text-base font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0]">
|
||||
FRASA<span className="text-accent">.</span>
|
||||
CRM360 FRASA<span className="text-accent">.</span>
|
||||
</span>
|
||||
|
||||
{/* Desktop nav */}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MODULOS: Modulo[] = [
|
||||
{
|
||||
id: 'chat-hermes',
|
||||
nombre: 'Chat Hermes',
|
||||
descripcion: 'Asistente IA con la base de conocimiento de Frasa',
|
||||
descripcion: 'Asistente IA con la base de conocimiento de CRM360 FRASA',
|
||||
claves: ['chat'],
|
||||
url: '/ai/',
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="manifest" href="/hoy/manifest.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/hoy/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Mi día</title>
|
||||
<title>CRM360 FRASA — Mi día</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "FRASA CRM",
|
||||
"short_name": "FRASA",
|
||||
"name": "CRM360 FRASA",
|
||||
"short_name": "CRM360",
|
||||
"start_url": "/hoy/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F8FAFC",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Frasa · Mi día",
|
||||
"name": "CRM360 FRASA · Mi día",
|
||||
"short_name": "Mi día",
|
||||
"start_url": "/hoy/",
|
||||
"scope": "/hoy/",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-lg font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">
|
||||
FRASA<span aria-hidden="true" className="text-[#F97316]">.</span>
|
||||
CRM360 FRASA<span aria-hidden="true" className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
import ChipPendienteSync from '../components/ChipPendienteSync'
|
||||
import type { AccionesSesion } from '../components/AuthGate'
|
||||
import {
|
||||
esActividadVencida,
|
||||
@@ -338,9 +336,9 @@ function FilaOportunidad({
|
||||
enviando: boolean
|
||||
acciones?: string[]
|
||||
}) {
|
||||
const [conCita, setConCita] = useState(false)
|
||||
const [fechaCita, setFechaCita] = useState('')
|
||||
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador'] as const
|
||||
const [conCita, setConCita] = useState(!!op.fechaCita)
|
||||
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
|
||||
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const
|
||||
return (
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -353,7 +351,7 @@ function FilaOportunidad({
|
||||
>
|
||||
{op.nombre}
|
||||
</button>
|
||||
{op.pendienteSync === true && <ChipPendienteSync />}
|
||||
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{op.telefono || 'Sin teléfono'}
|
||||
@@ -469,7 +467,7 @@ function FilaApartado({
|
||||
>
|
||||
{apartado.nombre}
|
||||
</button>
|
||||
{apartado.pendienteSync === true && <ChipPendienteSync />}
|
||||
|
||||
{apartado.unidadCodigo ? (
|
||||
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||
) : null}
|
||||
@@ -732,7 +730,7 @@ function FilaLead({
|
||||
{lead.nombre}
|
||||
</button>
|
||||
<ScoreBadge score={calcularScore(lead)} />
|
||||
{lead.pendienteSync === true && <ChipPendienteSync />}
|
||||
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<span
|
||||
className={
|
||||
@@ -816,7 +814,6 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
} | null>(null)
|
||||
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
||||
const [nota, setNota] = useState<string | null>(null)
|
||||
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
|
||||
const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null)
|
||||
const [apartadoCancelando, setApartadoCancelando] = useState<{
|
||||
@@ -993,13 +990,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
async function convertir(lead: LeadMiDia) {
|
||||
setEnviando(true)
|
||||
setAviso(null)
|
||||
setNota(null)
|
||||
try {
|
||||
await convertirLead(lead.id)
|
||||
await cargar()
|
||||
setNota(`${lead.nombre} pasó a Mis oportunidades (Agendo Cita).`)
|
||||
} catch (err) {
|
||||
setAviso(err instanceof Error ? err.message : 'No se pudo convertir el lead')
|
||||
toast.error(err instanceof Error ? err.message : 'No se pudo convertir el lead')
|
||||
} finally {
|
||||
setEnviando(false)
|
||||
}
|
||||
@@ -1017,7 +1012,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
try {
|
||||
await cambiarEtapaOportunidad(op.id, 'Cotizador')
|
||||
} catch {
|
||||
setAviso('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
|
||||
toast.error('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
|
||||
}
|
||||
window.location.href = urlCotizadorParaOportunidad(op)
|
||||
}
|
||||
@@ -1079,7 +1074,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
← Menú
|
||||
</a>
|
||||
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
|
||||
CRM360 FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
|
||||
</p>
|
||||
<span aria-hidden="true" className="hidden h-4 w-px bg-[#E5E7EB] sm:block" />
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@@ -1120,7 +1115,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<BannerModoLocal />
|
||||
|
||||
|
||||
{offline && (
|
||||
<div className="px-4 pt-4 sm:px-6 lg:px-8">
|
||||
@@ -1146,11 +1141,6 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
{aviso}
|
||||
</p>
|
||||
)}
|
||||
{nota && (
|
||||
<p role="status" className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
||||
{nota}
|
||||
</p>
|
||||
)}
|
||||
{resumen?.sinMapeoMonday && (
|
||||
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]">
|
||||
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
|
||||
@@ -1457,7 +1447,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{c.nombre}
|
||||
{c.pendienteSync === true && <ChipPendienteSync />}
|
||||
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{c.telefono || 'Sin teléfono'}
|
||||
|
||||
Reference in New Issue
Block a user