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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
56
mvp/b2c/src/components/panel/LeadFotosGaleria.tsx
Normal file
56
mvp/b2c/src/components/panel/LeadFotosGaleria.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
39
mvp/b2c/src/lib/funnel/fotos.ts
Normal file
39
mvp/b2c/src/lib/funnel/fotos.ts
Normal 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());
|
||||||
|
}
|
||||||
81
mvp/b2c/tests/panel/agrupar-zonas.test.ts
Normal file
81
mvp/b2c/tests/panel/agrupar-zonas.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user