Muestra fotos y notas por zona en la ficha del lead

- agruparPorZona (lib/funnel/fotos.ts): helper puro que agrupa fotos
  (antes/después) y notas por zona, con fallback al tipo del lead. 5 tests.
- getLead trae también lead_notas.
- LeadFotosGaleria: galería por zona (Antes/Después + notas) que sustituye el
  grid plano de la ficha del panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:22:12 +02:00
parent 0a5f8cba2b
commit 5df608f203
5 changed files with 186 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl'; import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor'; import ConceptosEditor from '@/components/panel/ConceptosEditor';
import OpinionLinkBox from '@/components/panel/OpinionLinkBox'; import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
import LeadFotosGaleria from '@/components/panel/LeadFotosGaleria';
import { import {
PIPELINE_LABEL, PIPELINE_LABEL,
PIPELINE_NEXT, PIPELINE_NEXT,
@@ -32,7 +33,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const data = await getLead(id); const data = await getLead(id);
if (!data) notFound(); if (!data) notFound();
const { lead, fotos, eventos, precision } = data; const { lead, fotos, notas, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage)); const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null; const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
@@ -275,15 +276,10 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
</Section> </Section>
</div> </div>
{/* Fotos subidas */} {/* Fotos y notas por zona */}
{fotos.length > 0 && ( {(fotos.length > 0 || notas.length > 0) && (
<Section title="Fotos subidas por el cliente"> <Section title="Fotos y detalles por zona">
<div className="flex flex-wrap gap-3"> <LeadFotosGaleria fotos={fotos} notas={notas} tipoLead={lead.tipoReforma} />
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
))}
</div>
</Section> </Section>
)} )}

View File

@@ -0,0 +1,56 @@
import type { Lead, LeadFoto, LeadNota } from '@/db/schema';
import { TIPO_LABEL } from '@/lib/funnel';
import { agruparPorZona } from '@/lib/funnel/fotos';
function Fila({ titulo, fotos }: { titulo: string; fotos: LeadFoto[] }) {
if (fotos.length === 0) return null;
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs uppercase tracking-wide font-semibold text-gray-400">{titulo}</span>
<div className="flex flex-wrap gap-2">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={f.id}
src={f.url}
alt=""
className="w-28 h-20 object-cover rounded-lg border border-gray-200"
/>
))}
</div>
</div>
);
}
// Galería de la ficha: fotos antes/después y notas agrupadas por zona.
export default function LeadFotosGaleria({
fotos,
notas,
tipoLead,
}: {
fotos: LeadFoto[];
notas: LeadNota[];
tipoLead: Lead['tipoReforma'];
}) {
const grupos = agruparPorZona(fotos, notas, tipoLead ?? 'otro');
if (grupos.length === 0) return null;
return (
<div className="flex flex-col gap-5">
{grupos.map((g) => (
<div key={g.zona} className="flex flex-col gap-3 border border-gray-200 rounded-lg p-4">
<span className="text-sm font-bold text-black">{TIPO_LABEL[g.zona]}</span>
<Fila titulo="Antes" fotos={g.antes} />
<Fila titulo="Después" fotos={g.despues} />
{g.notas.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-gray-600">
{g.notas.map((n) => (
<li key={n.id}> {n.texto}</li>
))}
</ul>
)}
</div>
))}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { db } from './index';
import { import {
leads, leads,
leadFotos, leadFotos,
leadNotas,
leadEstadoHistory, leadEstadoHistory,
leadPipelineEventos, leadPipelineEventos,
precisionHistory, precisionHistory,
@@ -31,8 +32,9 @@ export async function getLead(id: string) {
if (!lead) return null; if (!lead) return null;
const [fotos, eventos, historial, precision] = await Promise.all([ const [fotos, notas, eventos, historial, precision] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)), db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
db.select().from(leadNotas).where(eq(leadNotas.leadId, id)).orderBy(asc(leadNotas.createdAt)),
db db
.select() .select()
.from(leadPipelineEventos) .from(leadPipelineEventos)
@@ -46,7 +48,7 @@ export async function getLead(id: string) {
db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)), db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)),
]); ]);
return { lead, fotos, eventos, historial, precision: precision[0] ?? null }; return { lead, fotos, notas, eventos, historial, precision: precision[0] ?? null };
} }
export async function getResumen() { export async function getResumen() {

View File

@@ -0,0 +1,39 @@
import type { Lead, LeadFoto, LeadNota } from '@/db/schema';
export type Zona = NonNullable<Lead['tipoReforma']>;
export type ZonaAgrupada = {
zona: Zona;
antes: LeadFoto[];
despues: LeadFoto[];
notas: LeadNota[];
};
// Agrupa fotos y notas por zona, con fallback al tipo de reforma del lead para las filas sin zona
// (datos antiguos). Conserva el orden en que aparece cada zona. Función pura para poder testearla.
export function agruparPorZona(
fotos: LeadFoto[],
notas: LeadNota[],
tipoLead: Zona,
): ZonaAgrupada[] {
const mapa = new Map<Zona, ZonaAgrupada>();
const slot = (zona: Zona): ZonaAgrupada => {
let s = mapa.get(zona);
if (!s) {
s = { zona, antes: [], despues: [], notas: [] };
mapa.set(zona, s);
}
return s;
};
for (const f of fotos) {
const s = slot((f.zona ?? tipoLead) as Zona);
if (f.momento === 'despues') s.despues.push(f);
else s.antes.push(f);
}
for (const n of notas) {
slot((n.zona ?? tipoLead) as Zona).notas.push(n);
}
return Array.from(mapa.values());
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { agruparPorZona } from '@/lib/funnel/fotos';
import type { LeadFoto, LeadNota } from '@/db/schema';
function foto(p: Partial<LeadFoto>): LeadFoto {
return {
id: Math.random().toString(36).slice(2),
leadId: 'lead-1',
url: 'data:img',
momento: 'antes',
zona: null,
orden: 0,
createdAt: new Date(),
...p,
} as LeadFoto;
}
function nota(p: Partial<LeadNota>): LeadNota {
return {
id: Math.random().toString(36).slice(2),
leadId: 'lead-1',
zona: null,
texto: 'x',
origen: 'ep',
createdAt: new Date(),
...p,
} as LeadNota;
}
describe('agruparPorZona', () => {
it('separa antes y después dentro de cada zona', () => {
const grupos = agruparPorZona(
[
foto({ zona: 'bano', momento: 'antes' }),
foto({ zona: 'bano', momento: 'despues' }),
foto({ zona: 'bano', momento: 'antes' }),
],
[],
'otro',
);
expect(grupos).toHaveLength(1);
expect(grupos[0].zona).toBe('bano');
expect(grupos[0].antes).toHaveLength(2);
expect(grupos[0].despues).toHaveLength(1);
});
it('agrupa por zona distintas y mantiene el orden de aparición', () => {
const grupos = agruparPorZona(
[foto({ zona: 'cocina' }), foto({ zona: 'bano' }), foto({ zona: 'cocina' })],
[],
'otro',
);
expect(grupos.map((g) => g.zona)).toEqual(['cocina', 'bano']);
expect(grupos[0].antes).toHaveLength(2);
});
it('usa el tipo del lead como fallback cuando la zona es null', () => {
const grupos = agruparPorZona([foto({ zona: null })], [nota({ zona: null })], 'salon');
expect(grupos).toHaveLength(1);
expect(grupos[0].zona).toBe('salon');
expect(grupos[0].antes).toHaveLength(1);
expect(grupos[0].notas).toHaveLength(1);
});
it('asocia las notas a su zona', () => {
const grupos = agruparPorZona(
[foto({ zona: 'cocina' })],
[nota({ zona: 'cocina', texto: 'encimera de cuarzo' }), nota({ zona: 'bano', texto: 'ducha' })],
'otro',
);
const cocina = grupos.find((g) => g.zona === 'cocina');
const bano = grupos.find((g) => g.zona === 'bano');
expect(cocina?.notas.map((n) => n.texto)).toEqual(['encimera de cuarzo']);
expect(bano?.notas.map((n) => n.texto)).toEqual(['ducha']);
expect(bano?.antes).toHaveLength(0);
});
it('devuelve vacío sin fotos ni notas', () => {
expect(agruparPorZona([], [], 'cocina')).toEqual([]);
});
});