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]>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
"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>
|
|
);
|
|
}
|