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

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