Añade revisión pre-envío del reformista y PDF de presupuesto pulido
Adelanta de F1.5 a F2 la validación pre-envío: el panel permite elegir modo de envío (automático/revisión), editar los conceptos del presupuesto y enviar al cliente por WhatsApp (simulado). Añade datos de empresa y logo configurables en /panel/empresa y genera el presupuesto como PDF real descargable con esa marca vía @react-pdf/renderer, sustituyendo la vista HTML imprimible. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLead } from '@/db/queries';
|
||||
import EstadoControl from '@/components/panel/EstadoControl';
|
||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||
import {
|
||||
PIPELINE_LABEL,
|
||||
PIPELINE_NEXT,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
} from '@/lib/funnel';
|
||||
import { recalcularPresupuesto } from '../actions';
|
||||
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -34,6 +35,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
const desglose = snapshot?.result ?? null;
|
||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -216,52 +218,42 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
{/* Presupuesto desglosado */}
|
||||
<Section title="Presupuesto desglosado">
|
||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
|
||||
>
|
||||
Recalcular presupuesto
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
|
||||
>
|
||||
Recalcular desde el catálogo
|
||||
</button>
|
||||
</form>
|
||||
{desglose && (
|
||||
<a
|
||||
href={`/panel/${lead.id}/presupuesto`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm font-semibold text-gray-700 hover:border-gray-500"
|
||||
>
|
||||
Revisar PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{desglose ? (
|
||||
<div className="flex flex-col gap-4 mt-2">
|
||||
{/* Partidas */}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
|
||||
<th className="pb-2 font-semibold">Partida</th>
|
||||
<th className="pb-2 font-semibold text-right">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{desglose.partidas.map((partida) => (
|
||||
<tr key={partida.key} className="border-b border-gray-50">
|
||||
<td className="py-1.5 text-gray-700">{partida.label}</td>
|
||||
<td className="py-1.5 text-right text-black font-medium">
|
||||
{formatEuros(partida.importe)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{yaEnviado && (
|
||||
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-3 py-2">
|
||||
Presupuesto enviado al cliente por WhatsApp ✓
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtotal, factor zona, total */}
|
||||
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Subtotal</span>
|
||||
<span className="text-black font-medium">{formatEuros(desglose.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Factor de zona</span>
|
||||
<span className="text-black font-medium">×{desglose.factorZona.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 pt-2 border-t border-gray-200">
|
||||
<span className="text-black font-bold">Total estimado</span>
|
||||
<span className="text-black font-bold text-lg">{formatEuros(desglose.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Conceptos editables + subtotal/factor zona/total */}
|
||||
<ConceptosEditor
|
||||
leadId={lead.id}
|
||||
partidas={desglose.partidas}
|
||||
factorZona={desglose.factorZona}
|
||||
bloqueado={yaEnviado}
|
||||
/>
|
||||
|
||||
{/* Rango */}
|
||||
<div className="flex justify-between text-sm">
|
||||
@@ -300,6 +292,18 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
<p className="text-xs text-gray-400">
|
||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||
</p>
|
||||
|
||||
{/* Enviar al cliente (envío simulado: registra la entrega por WhatsApp) */}
|
||||
{!yaEnviado && (
|
||||
<form action={enviarPresupuesto.bind(null, lead.id)} className="border-t border-gray-200 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-semibold w-fit hover:bg-green-700"
|
||||
>
|
||||
Enviar al cliente por WhatsApp
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
|
||||
|
||||
46
mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
Normal file
46
mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { getLead } from '@/db/queries';
|
||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
||||
import { TIPO_LABEL } from '@/lib/funnel';
|
||||
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const data = await getLead(id);
|
||||
if (!data) notFound();
|
||||
|
||||
const { lead } = data;
|
||||
const empresa = await getTenantPerfil();
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
const desglose = snapshot?.result ?? null;
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
PresupuestoDoc({
|
||||
empresa,
|
||||
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
|
||||
reforma: {
|
||||
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
|
||||
fecha: lead.createdAt,
|
||||
},
|
||||
desglose,
|
||||
})
|
||||
);
|
||||
|
||||
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="presupuesto-${slug || lead.id}.pdf"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import type { BudgetInputs } from '@/budget/types';
|
||||
import { applyConceptoEdits } from '@/budget/edit';
|
||||
import type { BudgetInputs, BudgetResult, PartidaResult } from '@/budget/types';
|
||||
|
||||
type Estado = (typeof leads.estado.enumValues)[number];
|
||||
|
||||
@@ -61,6 +62,73 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
|
||||
revalidatePath(`/panel/${leadId}`);
|
||||
}
|
||||
|
||||
export async function editarConceptos(leadId: string, formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const [lead] = await db
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
|
||||
.limit(1);
|
||||
if (!lead) throw new Error('Lead no encontrado.');
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as ({ result: BudgetResult } & Record<string, unknown>) | null;
|
||||
if (!snapshot?.result) {
|
||||
throw new Error('El lead no tiene presupuesto que editar.');
|
||||
}
|
||||
|
||||
const keys = formData.getAll('key').map(String);
|
||||
const labels = formData.getAll('label').map(String);
|
||||
const importes = formData.getAll('importeEuros').map((v) => Number(v));
|
||||
|
||||
const partidas: PartidaResult[] = labels.map((label, i) => {
|
||||
const euros = importes[i];
|
||||
const importe = Number.isFinite(euros) ? Math.round(euros * 100) : 0;
|
||||
return { key: keys[i] || `custom-${i}`, label, importe };
|
||||
});
|
||||
|
||||
const edited = applyConceptoEdits(snapshot.result, partidas);
|
||||
|
||||
await db
|
||||
.update(leads)
|
||||
.set({
|
||||
presupuestoEstimado: edited.total,
|
||||
desgloseSnapshot: { ...snapshot, result: edited },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
|
||||
|
||||
revalidatePath('/panel');
|
||||
revalidatePath(`/panel/${leadId}`);
|
||||
}
|
||||
|
||||
export async function enviarPresupuesto(leadId: string) {
|
||||
const tenantId = await getTenantId();
|
||||
const [lead] = await db
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
|
||||
.limit(1);
|
||||
if (!lead) throw new Error('Lead no encontrado.');
|
||||
if (lead.presupuestoEstimado == null) {
|
||||
throw new Error('El lead no tiene presupuesto que enviar.');
|
||||
}
|
||||
|
||||
await db
|
||||
.update(leads)
|
||||
.set({ estado: 'presupuesto_enviado', pipelineStage: 'whatsapp_entregado', updatedAt: new Date() })
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
|
||||
|
||||
await db.insert(leadEstadoHistory).values({ leadId, estado: 'presupuesto_enviado' });
|
||||
await db.insert(leadPipelineEventos).values({
|
||||
leadId,
|
||||
stage: 'whatsapp_entregado',
|
||||
metadata: { via: 'whatsapp', simulado: true, total: lead.presupuestoEstimado },
|
||||
});
|
||||
|
||||
revalidatePath('/panel');
|
||||
revalidatePath(`/panel/${leadId}`);
|
||||
}
|
||||
|
||||
export async function recalcularPresupuesto(leadId: string) {
|
||||
const tenantId = await getTenantId();
|
||||
const [lead] = await db
|
||||
|
||||
66
mvp/b2c/src/app/panel/empresa/actions.ts
Normal file
66
mvp/b2c/src/app/panel/empresa/actions.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
'use server';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/db';
|
||||
import { tenants } from '@/db/schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
const LOGO_MAX_BYTES = 500_000;
|
||||
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
||||
|
||||
function limpiar(raw: FormDataEntryValue | null): string | null {
|
||||
const s = String(raw ?? '').trim();
|
||||
return s.length > 0 ? s : null;
|
||||
}
|
||||
|
||||
export async function actualizarEmpresa(formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const nombreEmpresa = limpiar(formData.get('nombreEmpresa'));
|
||||
if (!nombreEmpresa) {
|
||||
throw new Error('El nombre de la empresa es obligatorio.');
|
||||
}
|
||||
await db
|
||||
.update(tenants)
|
||||
.set({
|
||||
nombreEmpresa,
|
||||
cif: limpiar(formData.get('cif')),
|
||||
direccion: limpiar(formData.get('direccion')),
|
||||
provincia: limpiar(formData.get('provincia')),
|
||||
telefono: limpiar(formData.get('telefono')),
|
||||
email: limpiar(formData.get('email')),
|
||||
web: limpiar(formData.get('web')),
|
||||
})
|
||||
.where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
revalidatePath('/panel');
|
||||
}
|
||||
|
||||
export type LogoResult = { ok: boolean; error?: string };
|
||||
|
||||
export async function subirLogo(_prev: LogoResult | null, formData: FormData): Promise<LogoResult> {
|
||||
const tenantId = await getTenantId();
|
||||
const file = formData.get('logo');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return { ok: false, error: 'Selecciona un archivo de imagen.' };
|
||||
}
|
||||
if (!LOGO_TIPOS.includes(file.type)) {
|
||||
return { ok: false, error: 'Formato no válido. Usa PNG, JPG, WEBP o SVG.' };
|
||||
}
|
||||
if (file.size > LOGO_MAX_BYTES) {
|
||||
return { ok: false, error: 'El logo no puede superar los 500 KB.' };
|
||||
}
|
||||
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
|
||||
const dataUri = `data:${file.type};base64,${base64}`;
|
||||
await db.update(tenants).set({ logoUrl: dataUri }).where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
revalidatePath('/panel');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function quitarLogo() {
|
||||
const tenantId = await getTenantId();
|
||||
await db.update(tenants).set({ logoUrl: null }).where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
revalidatePath('/panel');
|
||||
}
|
||||
93
mvp/b2c/src/app/panel/empresa/page.tsx
Normal file
93
mvp/b2c/src/app/panel/empresa/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
||||
import { actualizarEmpresa } from './actions';
|
||||
import LogoUploader from '@/components/panel/LogoUploader';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EmpresaPage() {
|
||||
const perfil = await getTenantPerfil();
|
||||
|
||||
return (
|
||||
<div className="space-y-10 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
|
||||
cliente. Manténlos al día.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-4">Logo</h2>
|
||||
<LogoUploader logoUrl={perfil.logoUrl} />
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-4">Identidad</h2>
|
||||
<form action={actualizarEmpresa} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Nombre de la empresa *</span>
|
||||
<input
|
||||
name="nombreEmpresa"
|
||||
required
|
||||
defaultValue={perfil.nombreEmpresa}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-gray-500 mb-1">CIF / NIF</span>
|
||||
<input
|
||||
name="cif"
|
||||
defaultValue={perfil.cif ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-gray-500 mb-1">Provincia</span>
|
||||
<input
|
||||
name="provincia"
|
||||
defaultValue={perfil.provincia ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Dirección</span>
|
||||
<input
|
||||
name="direccion"
|
||||
defaultValue={perfil.direccion ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-gray-500 mb-1">Teléfono</span>
|
||||
<input
|
||||
name="telefono"
|
||||
defaultValue={perfil.telefono ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-gray-500 mb-1">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={perfil.email ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Web</span>
|
||||
<input
|
||||
name="web"
|
||||
defaultValue={perfil.web ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Guardar datos
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
||||
<nav className="flex items-center gap-4 text-xs font-medium">
|
||||
<Link href="/panel" className="text-gray-500 hover:text-black">Leads</Link>
|
||||
<Link href="/panel/precios" className="text-gray-500 hover:text-black">Precios</Link>
|
||||
<Link href="/panel/empresa" className="text-gray-500 hover:text-black">Empresa</Link>
|
||||
<form action="/logout" method="post">
|
||||
<button type="submit" className="text-gray-500 hover:text-black">Salir</button>
|
||||
</form>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/db';
|
||||
import { catalogItems, pricingConfig } from '@/db/schema';
|
||||
import { getTenantId } from '@/db/pricing-queries';
|
||||
import { catalogItems, pricingConfig, tenants } from '@/db/schema';
|
||||
import { getTenantId, type EnvioMode } from '@/db/pricing-queries';
|
||||
import { parseCatalogCsv } from '@/budget/csv';
|
||||
|
||||
// Valida un importe en euros del formulario y lo convierte a céntimos.
|
||||
@@ -76,6 +76,19 @@ export async function actualizarConfig(formData: FormData) {
|
||||
revalidatePath('/panel/precios');
|
||||
}
|
||||
|
||||
export async function actualizarEnvio(formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const modo = formData.get('modo');
|
||||
if (modo !== 'automatico' && modo !== 'revision') {
|
||||
throw new Error('Modo de envío no válido.');
|
||||
}
|
||||
await db
|
||||
.update(tenants)
|
||||
.set({ envioPresupuesto: modo as EnvioMode })
|
||||
.where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/precios');
|
||||
}
|
||||
|
||||
export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] };
|
||||
|
||||
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
|
||||
import { getPricingConfig, getCatalog, getEnvioMode } from '@/db/pricing-queries';
|
||||
import {
|
||||
crearMaterial,
|
||||
actualizarPrecio,
|
||||
borrarMaterial,
|
||||
actualizarConfig,
|
||||
actualizarEnvio,
|
||||
importarCatalogoCsv,
|
||||
} from './actions';
|
||||
|
||||
@@ -18,7 +19,11 @@ const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
|
||||
};
|
||||
|
||||
export default async function PreciosPage() {
|
||||
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
|
||||
const [config, catalog, envioMode] = await Promise.all([
|
||||
getPricingConfig(),
|
||||
getCatalog(),
|
||||
getEnvioMode(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
@@ -30,6 +35,50 @@ export default async function PreciosPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Envío de presupuestos */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-1">Envío de presupuestos</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Decide si el presupuesto se entrega al cliente automáticamente al final del funnel o si
|
||||
quieres revisarlo y editar los conceptos antes de enviarlo.
|
||||
</p>
|
||||
<form action={actualizarEnvio} className="flex flex-col gap-3">
|
||||
<label className="flex items-start gap-3 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="modo"
|
||||
value="automatico"
|
||||
defaultChecked={envioMode === 'automatico'}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-black">Envío automático</span>
|
||||
<span className="block text-gray-500">
|
||||
El cliente recibe el presupuesto por WhatsApp en cuanto el funnel lo genera.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="modo"
|
||||
value="revision"
|
||||
defaultChecked={envioMode === 'revision'}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-black">Revisar antes de enviar</span>
|
||||
<span className="block text-gray-500">
|
||||
El funnel se detiene en cada lead para que revises los conceptos y pulses enviar.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button className="self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Guardar preferencia
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Config general */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
||||
|
||||
Reference in New Issue
Block a user