feat: sitio comercial FSPM con micro CRM interno
Sitio público orientado a venta consultiva e implementación de las recomendaciones de la auditoría de marca y mercado: - Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador de riesgo en el héroe, contra el hallazgo de que el visitante tenía que inferir qué solución le correspondía. - Cinco páginas por segmento prioritario y catálogo comparable por clase de fuego, capacidad y agente. - Los claims sin evidencia por modelo quedan aislados y se presentan como compromiso de documentación, nunca como característica del producto. - SEO técnico completo: metadatos por página, sitemap derivado de los datos, JSON-LD, iconos y tarjeta social generadas. Micro CRM: - Captura por formulario embebido y por widget de WhatsApp que registra antes de abrir la conversación. - Atribución de primer toque que sobrevive a la navegación. - Deduplicación de contacto; un mismo contacto acumula oportunidades. - Motor de asignación por reglas con reparto por turno de respaldo. - Panel con dos roles de alcance distinto, tablero analítico, bitácora, pedidos y reglas. No enlazado desde el sitio público y con noindex. Empaquetado en Docker multietapa con la base de demostración horneada en la imagen y el volumen de datos separado, para que los leads capturados sobrevivan a los redespliegues. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
122e06a4bb
commit
01b817d098
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { IVA } from "@/lib/crm/constantes";
|
||||
import type { ItemPedido } from "@/lib/crm/tipos";
|
||||
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
|
||||
import { accionCrearPedido } from "@/lib/crm/acciones";
|
||||
import { moneda } from "@/components/admin/formato";
|
||||
import { AvisoAccion, usarAccion } from "@/components/admin/usarAccion";
|
||||
|
||||
/**
|
||||
* Alta de pedido desde una oportunidad ganada.
|
||||
*
|
||||
* Las partidas salen del catálogo real: no se puede teclear un producto que no
|
||||
* existe. El precio unitario sí se captura a mano y a propósito —el catálogo de
|
||||
* FSPM no publica precios, así que inventarlos aquí sería fabricar un dato.
|
||||
*
|
||||
* Subtotal, IVA y total se calculan en vivo, antes de enviar: quien cotiza ve
|
||||
* la cifra final mientras arma la partida, no después.
|
||||
*/
|
||||
|
||||
type Partida = {
|
||||
/** Clave local de la fila, no viaja al servidor. */
|
||||
llave: string;
|
||||
productoSlug: string;
|
||||
cantidad: string;
|
||||
precioUnitario: string;
|
||||
};
|
||||
|
||||
const ESTILO_CAMPO = { appearance: "none", WebkitAppearance: "none" } as const;
|
||||
|
||||
const PRODUCTOS_POR_CATEGORIA = CATEGORIAS.map((c) => ({
|
||||
categoria: c,
|
||||
productos: PRODUCTOS.filter((p) => p.categoria === c.slug).sort((a, b) => a.orden - b.orden),
|
||||
})).filter((g) => g.productos.length > 0);
|
||||
|
||||
let contador = 0;
|
||||
function nuevaPartida(productoSlug = ""): Partida {
|
||||
contador += 1;
|
||||
return { llave: `p${contador}`, productoSlug, cantidad: "1", precioUnitario: "" };
|
||||
}
|
||||
|
||||
function numero(v: string): number {
|
||||
const n = Number(v.replace(/[^0-9.]/g, ""));
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
export function CrearPedido({
|
||||
oportunidadId,
|
||||
productoSlugSugerido,
|
||||
}: {
|
||||
oportunidadId: string;
|
||||
/** El producto que declaró el lead, para no empezar de cero. */
|
||||
productoSlugSugerido: string | null;
|
||||
}) {
|
||||
const { enviando, aviso, ejecutar } = usarAccion();
|
||||
const [abierto, setAbierto] = useState(false);
|
||||
const [partidas, setPartidas] = useState<Partida[]>([
|
||||
nuevaPartida(
|
||||
productoSlugSugerido && PRODUCTOS.some((p) => p.slug === productoSlugSugerido)
|
||||
? productoSlugSugerido
|
||||
: "",
|
||||
),
|
||||
]);
|
||||
const [notas, setNotas] = useState("");
|
||||
|
||||
const totales = useMemo(() => {
|
||||
const subtotal = partidas.reduce(
|
||||
(s, p) => s + numero(p.cantidad) * numero(p.precioUnitario),
|
||||
0,
|
||||
);
|
||||
const iva = subtotal * IVA;
|
||||
return { subtotal, iva, total: subtotal + iva };
|
||||
}, [partidas]);
|
||||
|
||||
const validas = partidas.filter(
|
||||
(p) => p.productoSlug !== "" && numero(p.cantidad) > 0 && numero(p.precioUnitario) > 0,
|
||||
);
|
||||
|
||||
function actualizar(llave: string, campo: keyof Omit<Partida, "llave">, valor: string) {
|
||||
setPartidas((prev) =>
|
||||
prev.map((p) => (p.llave === llave ? { ...p, [campo]: valor } : p)),
|
||||
);
|
||||
}
|
||||
|
||||
function enviar() {
|
||||
if (validas.length === 0) return;
|
||||
const items: ItemPedido[] = validas.map((p) => {
|
||||
const producto = PRODUCTOS.find((x) => x.slug === p.productoSlug);
|
||||
return {
|
||||
productoSlug: p.productoSlug,
|
||||
nombre: producto?.nombre ?? p.productoSlug,
|
||||
cantidad: numero(p.cantidad),
|
||||
precioUnitario: numero(p.precioUnitario),
|
||||
};
|
||||
});
|
||||
void ejecutar(async () => {
|
||||
const r = await accionCrearPedido(oportunidadId, items, notas.trim() || undefined);
|
||||
if (r.ok) {
|
||||
setPartidas([nuevaPartida()]);
|
||||
setNotas("");
|
||||
setAbierto(false);
|
||||
}
|
||||
return r;
|
||||
}, "Pedido creado. Búscalo en el listado de pedidos.");
|
||||
}
|
||||
|
||||
if (!abierto) {
|
||||
return (
|
||||
<div className="px-4 py-4 sm:px-5">
|
||||
<p className="mb-3 text-xs leading-relaxed text-muted">
|
||||
Esta oportunidad está ganada: ya se le puede levantar el pedido con las partidas
|
||||
acordadas.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAbierto(true)}
|
||||
className="btn btn-signal btn-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
>
|
||||
Crear pedido
|
||||
</button>
|
||||
<AvisoAccion aviso={aviso} className="mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 px-4 py-4 sm:px-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
enviar();
|
||||
}}
|
||||
>
|
||||
<ul className="grid gap-3">
|
||||
{partidas.map((p, i) => {
|
||||
const producto = PRODUCTOS.find((x) => x.slug === p.productoSlug);
|
||||
const importe = numero(p.cantidad) * numero(p.precioUnitario);
|
||||
return (
|
||||
<li
|
||||
key={p.llave}
|
||||
className="grid gap-2 rounded-[3px] border border-rule bg-ink px-3 py-3"
|
||||
>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_5rem_8rem]">
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={`pd-prod-${p.llave}`} className="field-label">
|
||||
Partida {i + 1}
|
||||
</label>
|
||||
<select
|
||||
id={`pd-prod-${p.llave}`}
|
||||
value={p.productoSlug}
|
||||
onChange={(e) => actualizar(p.llave, "productoSlug", e.target.value)}
|
||||
className="field w-full py-2 text-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
>
|
||||
<option value="">Elige un producto del catálogo</option>
|
||||
{PRODUCTOS_POR_CATEGORIA.map((g) => (
|
||||
<optgroup key={g.categoria.slug} label={g.categoria.nombre}>
|
||||
{g.productos.map((prod) => (
|
||||
<option key={prod.slug} value={prod.slug}>
|
||||
{prod.nombre}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`pd-cant-${p.llave}`} className="field-label">
|
||||
Cantidad
|
||||
</label>
|
||||
<input
|
||||
id={`pd-cant-${p.llave}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={p.cantidad}
|
||||
onChange={(e) => actualizar(p.llave, "cantidad", e.target.value)}
|
||||
className="field w-full py-2 text-right font-mono text-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`pd-precio-${p.llave}`} className="field-label">
|
||||
Precio unitario
|
||||
</label>
|
||||
<input
|
||||
id={`pd-precio-${p.llave}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={p.precioUnitario}
|
||||
onChange={(e) => actualizar(p.llave, "precioUnitario", e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="field w-full py-2 text-right font-mono text-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="min-w-0 font-mono text-[0.5625rem] uppercase tracking-[0.08em] text-faint">
|
||||
{producto
|
||||
? `${producto.modelo ?? "sin modelo"} · ${producto.capacidad ?? "capacidad no declarada"}`
|
||||
: "Sin producto elegido"}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-mono text-[0.8125rem] font-semibold tabular-nums text-bright">
|
||||
{moneda(importe)}
|
||||
</p>
|
||||
{partidas.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPartidas((prev) => prev.filter((x) => x.llave !== p.llave))
|
||||
}
|
||||
className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-signal-hi underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
|
||||
style={ESTILO_CAMPO}
|
||||
>
|
||||
Quitar
|
||||
<span className="sr-only"> la partida {i + 1}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPartidas((prev) => [...prev, nuevaPartida()])}
|
||||
className="btn btn-ghost btn-sm justify-self-start"
|
||||
style={ESTILO_CAMPO}
|
||||
>
|
||||
Agregar partida
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label htmlFor="pd-notas" className="field-label">
|
||||
Notas del pedido (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
id="pd-notas"
|
||||
rows={2}
|
||||
value={notas}
|
||||
onChange={(e) => setNotas(e.target.value)}
|
||||
placeholder="Entrega en almacén 2, previa cita con seguridad patrimonial."
|
||||
className="field w-full py-2 text-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Totales en vivo: la cifra final se ve antes de enviar, no después. */}
|
||||
<dl className="spec grid-cols-3" aria-live="polite">
|
||||
<div className="spec-cell">
|
||||
<dt className="spec-key">Subtotal</dt>
|
||||
<dd className="spec-val">{moneda(totales.subtotal)}</dd>
|
||||
</div>
|
||||
<div className="spec-cell">
|
||||
<dt className="spec-key">IVA {Math.round(IVA * 100)} %</dt>
|
||||
<dd className="spec-val">{moneda(totales.iva)}</dd>
|
||||
</div>
|
||||
<div className="spec-cell">
|
||||
<dt className="spec-key">Total</dt>
|
||||
<dd className="spec-val text-signal-hi">{moneda(totales.total)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={enviando || validas.length === 0}
|
||||
className="btn btn-signal btn-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
>
|
||||
{enviando ? "Creando…" : "Crear pedido"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
onClick={() => setAbierto(false)}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={ESTILO_CAMPO}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<AvisoAccion aviso={aviso} />
|
||||
</div>
|
||||
|
||||
{validas.length === 0 ? (
|
||||
<p className="font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.1em] text-faint">
|
||||
Falta al menos una partida con producto, cantidad y precio unitario
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user