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:
Carlos Narro
2026-05-30 22:27:05 +02:00
parent b84b2f37a2
commit ec141cdd6e
26 changed files with 3961 additions and 59 deletions

View File

@@ -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>

View 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',
},
});
}

View File

@@ -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

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

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

View File

@@ -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>

View File

@@ -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> {

View File

@@ -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>

View 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,
};
}

View File

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

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

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

View File

@@ -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,

View File

@@ -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(),

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

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