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>
|
||||
|
||||
30
mvp/b2c/src/budget/edit.ts
Normal file
30
mvp/b2c/src/budget/edit.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { BudgetResult, PartidaResult } from './types';
|
||||
|
||||
export const AVISO_EDITADO = 'Presupuesto ajustado manualmente por el reformista.';
|
||||
|
||||
// Aplica la edición manual de conceptos del reformista sobre un presupuesto ya calculado.
|
||||
// Conserva el factor de zona del cálculo original; el reformista ha validado las cifras,
|
||||
// así que la confianza pasa a alta y el rango colapsa al total.
|
||||
export function applyConceptoEdits(prev: BudgetResult, partidas: PartidaResult[]): BudgetResult {
|
||||
const clean: PartidaResult[] = partidas
|
||||
.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label.trim(),
|
||||
importe: Math.max(0, Math.round(p.importe)),
|
||||
}))
|
||||
.filter((p) => p.label.length > 0);
|
||||
|
||||
const subtotal = clean.reduce((s, p) => s + p.importe, 0);
|
||||
const total = Math.round(subtotal * prev.factorZona);
|
||||
const avisos = prev.avisos.includes(AVISO_EDITADO) ? prev.avisos : [...prev.avisos, AVISO_EDITADO];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
partidas: clean,
|
||||
subtotal,
|
||||
total,
|
||||
rango: { min: total, max: total },
|
||||
confianza: 'alta',
|
||||
avisos,
|
||||
};
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export interface BudgetInputs {
|
||||
}
|
||||
|
||||
export interface PartidaResult {
|
||||
key: PartidaKey;
|
||||
// PartidaKey para las partidas que genera el motor; string libre (p. ej. 'custom-1')
|
||||
// para las que añade el reformista a mano en la revisión.
|
||||
key: PartidaKey | string;
|
||||
label: string;
|
||||
importe: number; // céntimos (base, antes de factor zona)
|
||||
}
|
||||
|
||||
138
mvp/b2c/src/components/panel/ConceptosEditor.tsx
Normal file
138
mvp/b2c/src/components/panel/ConceptosEditor.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { editarConceptos } from '@/app/panel/actions';
|
||||
import { formatEuros } from '@/lib/funnel';
|
||||
import type { PartidaResult } from '@/budget/types';
|
||||
|
||||
type Row = { key: string; label: string; euros: string };
|
||||
|
||||
function toRows(partidas: PartidaResult[]): Row[] {
|
||||
return partidas.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
euros: (p.importe / 100).toFixed(2),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function ConceptosEditor({
|
||||
leadId,
|
||||
partidas,
|
||||
factorZona,
|
||||
bloqueado,
|
||||
}: {
|
||||
leadId: string;
|
||||
partidas: PartidaResult[];
|
||||
factorZona: number;
|
||||
bloqueado: boolean;
|
||||
}) {
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(partidas));
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [guardado, setGuardado] = useState(false);
|
||||
|
||||
const subtotal = rows.reduce((s, r) => {
|
||||
const euros = Number(r.euros);
|
||||
return s + (Number.isFinite(euros) && euros > 0 ? Math.round(euros * 100) : 0);
|
||||
}, 0);
|
||||
const total = Math.round(subtotal * factorZona);
|
||||
|
||||
function updateRow(i: number, patch: Partial<Row>) {
|
||||
setRows((prev) => prev.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
||||
setGuardado(false);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
setRows((prev) => prev.filter((_, j) => j !== i));
|
||||
setGuardado(false);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setRows((prev) => [...prev, { key: `custom-${Date.now()}`, label: '', euros: '0.00' }]);
|
||||
setGuardado(false);
|
||||
}
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
startTransition(async () => {
|
||||
await editarConceptos(leadId, formData);
|
||||
setGuardado(true);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={row.key} className="flex items-center gap-2">
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input
|
||||
name="label"
|
||||
value={row.label}
|
||||
onChange={(e) => updateRow(i, { label: e.target.value })}
|
||||
disabled={bloqueado}
|
||||
placeholder="Concepto"
|
||||
className="flex-1 border border-gray-300 rounded-lg px-2 py-1 text-sm text-gray-700 disabled:bg-gray-50"
|
||||
/>
|
||||
<input
|
||||
name="importeEuros"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={row.euros}
|
||||
onChange={(e) => updateRow(i, { euros: e.target.value })}
|
||||
disabled={bloqueado}
|
||||
className="w-28 border border-gray-300 rounded-lg px-2 py-1 text-sm text-right text-black disabled:bg-gray-50"
|
||||
/>
|
||||
<span className="text-gray-400 text-sm w-4">€</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(i)}
|
||||
disabled={bloqueado}
|
||||
aria-label="Quitar concepto"
|
||||
className="text-red-500 text-sm w-6 hover:text-red-700 disabled:opacity-30"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!bloqueado && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="self-start text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
+ Añadir concepto
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Factor de zona</span>
|
||||
<span className="text-black font-medium">×{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(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!bloqueado && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Guardando…' : 'Guardar conceptos'}
|
||||
</button>
|
||||
{guardado && <span className="text-sm text-green-600">Guardado ✓</span>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
50
mvp/b2c/src/components/panel/LogoUploader.tsx
Normal file
50
mvp/b2c/src/components/panel/LogoUploader.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions';
|
||||
|
||||
export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(subirLogo, null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32 h-20 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin logo</span>
|
||||
)}
|
||||
</div>
|
||||
{logoUrl && (
|
||||
<form action={quitarLogo}>
|
||||
<button type="submit" className="text-xs text-red-500 hover:underline">
|
||||
Quitar logo
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
name="logo"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Subiendo…' : 'Subir logo'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Logo actualizado ✓</p>}
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP o SVG · máx. 500 KB.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { pricingConfig, catalogItems } from './schema';
|
||||
import { pricingConfig, catalogItems, tenants } from './schema';
|
||||
import type { PricingConfig, CatalogItem, ManoObraKey } from '@/budget/types';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
export type EnvioMode = (typeof tenants.envioPresupuesto.enumValues)[number];
|
||||
|
||||
export async function getEnvioMode(): Promise<EnvioMode> {
|
||||
const tenantId = await getTenantId();
|
||||
const [row] = await db
|
||||
.select({ modo: tenants.envioPresupuesto })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1);
|
||||
return row?.modo ?? 'automatico';
|
||||
}
|
||||
|
||||
const MANO_OBRA_DEFAULT: Record<ManoObraKey, number> = {
|
||||
demolicion: 0,
|
||||
fontaneria: 0,
|
||||
|
||||
@@ -65,17 +65,28 @@ export const subscriptionStatus = pgEnum('subscription_status', [
|
||||
'vencido',
|
||||
]);
|
||||
|
||||
// Cómo entrega el reformista el presupuesto al cliente final.
|
||||
// 'automatico' = el funnel lo envía solo; 'revision' = se para para que el reformista lo revise/edite antes de enviar.
|
||||
export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatico', 'revision']);
|
||||
|
||||
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
|
||||
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
||||
export const tenants = pgTable('tenants', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
nombreEmpresa: text('nombre_empresa').notNull(),
|
||||
logoUrl: text('logo_url'),
|
||||
logoUrl: text('logo_url'), // data URI base64 del logo (no hay storage externo aún)
|
||||
provincia: text('provincia'),
|
||||
whatsappBusiness: text('whatsapp_business'),
|
||||
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
|
||||
cif: text('cif'),
|
||||
direccion: text('direccion'),
|
||||
telefono: text('telefono'),
|
||||
email: text('email'),
|
||||
web: text('web'),
|
||||
planId: uuid('plan_id').references((): AnyPgColumn => plans.id),
|
||||
subscriptionStatus: subscriptionStatus('subscription_status').notNull().default('trial'),
|
||||
envioPresupuesto: envioPresupuestoMode('envio_presupuesto').notNull().default('automatico'),
|
||||
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
46
mvp/b2c/src/db/tenant-queries.ts
Normal file
46
mvp/b2c/src/db/tenant-queries.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { tenants } from './schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
export type TenantPerfil = {
|
||||
nombreEmpresa: string;
|
||||
logoUrl: string | null;
|
||||
provincia: string | null;
|
||||
cif: string | null;
|
||||
direccion: string | null;
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
web: string | null;
|
||||
};
|
||||
|
||||
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
const tenantId = await getTenantId();
|
||||
const [row] = await db
|
||||
.select({
|
||||
nombreEmpresa: tenants.nombreEmpresa,
|
||||
logoUrl: tenants.logoUrl,
|
||||
provincia: tenants.provincia,
|
||||
cif: tenants.cif,
|
||||
direccion: tenants.direccion,
|
||||
telefono: tenants.telefono,
|
||||
email: tenants.email,
|
||||
web: tenants.web,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
return (
|
||||
row ?? {
|
||||
nombreEmpresa: 'Reformix',
|
||||
logoUrl: null,
|
||||
provincia: null,
|
||||
cif: null,
|
||||
direccion: null,
|
||||
telefono: null,
|
||||
email: null,
|
||||
web: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
248
mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
Normal file
248
mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Document, Page, Text, View, Image, StyleSheet } from '@react-pdf/renderer';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
import type { TenantPerfil } from '@/db/tenant-queries';
|
||||
|
||||
const COLOR = {
|
||||
black: '#0a0a0a',
|
||||
dark: '#111111',
|
||||
gray600: '#555555',
|
||||
gray400: '#888888',
|
||||
gray200: '#e5e5e5',
|
||||
gray100: '#f5f5f5',
|
||||
accent: '#0066ff',
|
||||
accentLight: '#e8f0fe',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 56,
|
||||
paddingHorizontal: 44,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Helvetica',
|
||||
color: COLOR.dark,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: COLOR.black,
|
||||
},
|
||||
empresaNombre: { fontSize: 18, fontFamily: 'Helvetica-Bold', color: COLOR.black },
|
||||
empresaDato: { fontSize: 8, color: COLOR.gray600, marginTop: 1 },
|
||||
logo: { maxHeight: 48, maxWidth: 140, objectFit: 'contain' },
|
||||
docTitle: {
|
||||
marginTop: 18,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: COLOR.black,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 14,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: COLOR.gray100,
|
||||
borderRadius: 6,
|
||||
},
|
||||
metaLabel: {
|
||||
fontSize: 7,
|
||||
color: COLOR.gray400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaValueBold: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black },
|
||||
metaValue: { fontSize: 9, color: COLOR.gray600 },
|
||||
tableHead: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLOR.gray200,
|
||||
paddingBottom: 6,
|
||||
marginTop: 24,
|
||||
},
|
||||
thConcepto: {
|
||||
flex: 1,
|
||||
fontSize: 7,
|
||||
color: COLOR.gray400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
},
|
||||
thImporte: {
|
||||
width: 90,
|
||||
textAlign: 'right',
|
||||
fontSize: 7,
|
||||
color: COLOR.gray400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLOR.gray100,
|
||||
},
|
||||
tdConcepto: { flex: 1, fontSize: 10, color: COLOR.dark },
|
||||
tdImporte: { width: 90, textAlign: 'right', fontSize: 10, fontFamily: 'Helvetica-Bold' },
|
||||
totalsBox: { marginTop: 16, marginLeft: 'auto', width: 220 },
|
||||
totalsLine: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 2 },
|
||||
totalsLabel: { fontSize: 9, color: COLOR.gray600 },
|
||||
totalsValue: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: COLOR.dark },
|
||||
totalFinal: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 10,
|
||||
backgroundColor: COLOR.accentLight,
|
||||
borderRadius: 6,
|
||||
},
|
||||
totalFinalLabel: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black },
|
||||
totalFinalValue: { fontSize: 16, fontFamily: 'Helvetica-Bold', color: COLOR.accent },
|
||||
rango: { marginTop: 8, fontSize: 8, color: COLOR.gray400, textAlign: 'right' },
|
||||
avisos: { marginTop: 20 },
|
||||
avisoTitle: {
|
||||
fontSize: 7,
|
||||
color: COLOR.gray400,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
avisoItem: { fontSize: 8, color: COLOR.gray600, marginBottom: 2 },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 28,
|
||||
left: 44,
|
||||
right: 44,
|
||||
paddingTop: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLOR.gray200,
|
||||
fontSize: 7,
|
||||
color: COLOR.gray400,
|
||||
textAlign: 'center',
|
||||
},
|
||||
empty: { marginTop: 40, fontSize: 11, color: COLOR.gray400, textAlign: 'center' },
|
||||
});
|
||||
|
||||
const fmtEuros = (cents: number) =>
|
||||
new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
|
||||
const fmtFecha = (date: Date) =>
|
||||
new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(date);
|
||||
|
||||
export type PresupuestoDocProps = {
|
||||
empresa: TenantPerfil;
|
||||
cliente: { nombre: string; telefono: string; provincia: string | null };
|
||||
reforma: { tipoLabel: string; fecha: Date };
|
||||
desglose: BudgetResult | null;
|
||||
};
|
||||
|
||||
export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: PresupuestoDocProps) {
|
||||
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`Presupuesto ${empresa.nombreEmpresa} — ${cliente.nombre}`}
|
||||
author={empresa.nombreEmpresa}
|
||||
>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.empresaNombre}>{empresa.nombreEmpresa}</Text>
|
||||
{empresa.cif ? <Text style={styles.empresaDato}>CIF: {empresa.cif}</Text> : null}
|
||||
{empresa.direccion ? <Text style={styles.empresaDato}>{empresa.direccion}</Text> : null}
|
||||
{contacto ? <Text style={styles.empresaDato}>{contacto}</Text> : null}
|
||||
</View>
|
||||
{empresa.logoUrl ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop
|
||||
<Image src={empresa.logoUrl} style={styles.logo} />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.docTitle}>PRESUPUESTO ORIENTATIVO DE REFORMA</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View>
|
||||
<Text style={styles.metaLabel}>Cliente</Text>
|
||||
<Text style={styles.metaValueBold}>{cliente.nombre}</Text>
|
||||
<Text style={styles.metaValue}>{cliente.telefono}</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Text style={styles.metaLabel}>Reforma</Text>
|
||||
<Text style={styles.metaValueBold}>{reforma.tipoLabel}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{(cliente.provincia ?? '—') + ' · ' + fmtFecha(reforma.fecha)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{desglose ? (
|
||||
<>
|
||||
<View style={styles.tableHead}>
|
||||
<Text style={styles.thConcepto}>Concepto</Text>
|
||||
<Text style={styles.thImporte}>Importe</Text>
|
||||
</View>
|
||||
{desglose.partidas.map((p, i) => (
|
||||
<View style={styles.row} key={`${p.key}-${i}`}>
|
||||
<Text style={styles.tdConcepto}>{p.label}</Text>
|
||||
<Text style={styles.tdImporte}>{fmtEuros(p.importe)}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.totalsBox}>
|
||||
<View style={styles.totalsLine}>
|
||||
<Text style={styles.totalsLabel}>Subtotal</Text>
|
||||
<Text style={styles.totalsValue}>{fmtEuros(desglose.subtotal)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalsLine}>
|
||||
<Text style={styles.totalsLabel}>Factor de zona</Text>
|
||||
<Text style={styles.totalsValue}>×{desglose.factorZona.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalFinal}>
|
||||
<Text style={styles.totalFinalLabel}>Total estimado</Text>
|
||||
<Text style={styles.totalFinalValue}>{fmtEuros(desglose.total)}</Text>
|
||||
</View>
|
||||
{desglose.rango.min !== desglose.rango.max ? (
|
||||
<Text style={styles.rango}>
|
||||
Rango: {fmtEuros(desglose.rango.min)} – {fmtEuros(desglose.rango.max)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{desglose.avisos.length > 0 ? (
|
||||
<View style={styles.avisos}>
|
||||
<Text style={styles.avisoTitle}>Notas</Text>
|
||||
{desglose.avisos.map((a, i) => (
|
||||
<Text style={styles.avisoItem} key={i}>
|
||||
• {a}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.empty}>Este lead aún no tiene presupuesto calculado.</Text>
|
||||
)}
|
||||
|
||||
<Text style={styles.footer} fixed>
|
||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||
{' · '}
|
||||
{empresa.nombreEmpresa}
|
||||
</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user