Files
fspm-web/src/components/admin/oportunidad/CrearPedido.tsx
T
Uriel JarethandClaude Opus 5 01b817d098 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]>
2026-08-28 00:07:20 -06:00

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>
);
}