diff --git a/mvp/b2c/src/app/panel/[id]/page.tsx b/mvp/b2c/src/app/panel/[id]/page.tsx index 47a15a2..16742c3 100644 --- a/mvp/b2c/src/app/panel/[id]/page.tsx +++ b/mvp/b2c/src/app/panel/[id]/page.tsx @@ -5,6 +5,7 @@ import { getLead } from '@/db/queries'; import EstadoControl from '@/components/panel/EstadoControl'; import ConceptosEditor from '@/components/panel/ConceptosEditor'; import OpinionLinkBox from '@/components/panel/OpinionLinkBox'; +import LeadFotosGaleria from '@/components/panel/LeadFotosGaleria'; import { PIPELINE_LABEL, PIPELINE_NEXT, @@ -32,7 +33,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id: const data = await getLead(id); 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 snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null; @@ -275,15 +276,10 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id: - {/* Fotos subidas */} - {fotos.length > 0 && ( -
-
- {fotos.map((f) => ( - // eslint-disable-next-line @next/next/no-img-element - - ))} -
+ {/* Fotos y notas por zona */} + {(fotos.length > 0 || notas.length > 0) && ( +
+
)} diff --git a/mvp/b2c/src/components/panel/LeadFotosGaleria.tsx b/mvp/b2c/src/components/panel/LeadFotosGaleria.tsx new file mode 100644 index 0000000..0be913c --- /dev/null +++ b/mvp/b2c/src/components/panel/LeadFotosGaleria.tsx @@ -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 ( +
+ {titulo} +
+ {fotos.map((f) => ( + // eslint-disable-next-line @next/next/no-img-element + + ))} +
+
+ ); +} + +// 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 ( +
+ {grupos.map((g) => ( +
+ {TIPO_LABEL[g.zona]} + + + {g.notas.length > 0 && ( +
    + {g.notas.map((n) => ( +
  • • {n.texto}
  • + ))} +
+ )} +
+ ))} +
+ ); +} diff --git a/mvp/b2c/src/db/queries.ts b/mvp/b2c/src/db/queries.ts index d3862aa..f553985 100644 --- a/mvp/b2c/src/db/queries.ts +++ b/mvp/b2c/src/db/queries.ts @@ -3,6 +3,7 @@ import { db } from './index'; import { leads, leadFotos, + leadNotas, leadEstadoHistory, leadPipelineEventos, precisionHistory, @@ -31,8 +32,9 @@ export async function getLead(id: string) { 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(leadNotas).where(eq(leadNotas.leadId, id)).orderBy(asc(leadNotas.createdAt)), db .select() .from(leadPipelineEventos) @@ -46,7 +48,7 @@ export async function getLead(id: string) { 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() { diff --git a/mvp/b2c/src/lib/funnel/fotos.ts b/mvp/b2c/src/lib/funnel/fotos.ts new file mode 100644 index 0000000..2f08e72 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/fotos.ts @@ -0,0 +1,39 @@ +import type { Lead, LeadFoto, LeadNota } from '@/db/schema'; + +export type Zona = NonNullable; + +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(); + 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()); +} diff --git a/mvp/b2c/tests/panel/agrupar-zonas.test.ts b/mvp/b2c/tests/panel/agrupar-zonas.test.ts new file mode 100644 index 0000000..285249b --- /dev/null +++ b/mvp/b2c/tests/panel/agrupar-zonas.test.ts @@ -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 { + 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 { + 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([]); + }); +});