feat(05-03): add getClientActiveOffers query + active offers section on client detail page

- Add ClientActiveOfferSummary type to admin-queries.ts
- Add getClientActiveOffers() function joining project_offers with offer_micros
- Only selects public_name (never internal_name) per CLAUDE.md constraint
- Update /admin/clients/[id] to fetch active offers in parallel with project data
- Add Offerte Attive section at bottom of client detail page
This commit is contained in:
2026-05-30 16:28:09 +02:00
parent 0c09d44b68
commit 20f4fd8603
2 changed files with 71 additions and 2 deletions
+28 -2
View File
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getClientWithProjects } from "@/lib/admin-queries"; import { getClientWithProjects, getClientActiveOffers } from "@/lib/admin-queries";
import { ClientActions } from "@/components/admin/ClientActions"; import { ClientActions } from "@/components/admin/ClientActions";
import Link from "next/link"; import Link from "next/link";
@@ -11,7 +11,10 @@ export default async function ClientDetailPage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const data = await getClientWithProjects(id); const [data, activeOffers] = await Promise.all([
getClientWithProjects(id),
getClientActiveOffers(id),
]);
if (!data) notFound(); if (!data) notFound();
const { projects, ...client } = data; const { projects, ...client } = data;
@@ -105,6 +108,29 @@ export default async function ClientDetailPage({
</div> </div>
</div> </div>
)} )}
{activeOffers.length > 0 && (
<div className="mt-8">
<p className="text-xs text-[#71717a] font-semibold uppercase tracking-wider mb-3">
Offerte Attive ({activeOffers.length})
</p>
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
{activeOffers.map((offer) => (
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
<p className="text-xs text-[#71717a]">{offer.project_name}</p>
</div>
{offer.accepted_total && (
<span className="text-sm font-mono text-[#1a1a1a]">
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }
+43
View File
@@ -642,3 +642,46 @@ export async function getClientWithProjects(clientId: string): Promise<ClientWit
})), })),
}; };
} }
// ── ClientActiveOfferSummary — used by /admin/clients/[id] offers section ─────
export type ClientActiveOfferSummary = {
offer_id: string;
project_id: string;
project_name: string;
public_name: string; // offer_micros.public_name — NEVER internal_name
accepted_total: string | null;
};
export async function getClientActiveOffers(clientId: string): Promise<ClientActiveOfferSummary[]> {
const projectRows = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.client_id, clientId));
if (projectRows.length === 0) return [];
const projectIds = projectRows.map((p) => p.id);
const projectNameMap = new Map(projectRows.map((p) => [p.id, p.name]));
// Explicit column selection — public_name only, NEVER internal_name
const rows = await db
.select({
offer_id: project_offers.id,
project_id: project_offers.project_id,
public_name: offer_micros.public_name,
accepted_total: project_offers.accepted_total,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.where(inArray(project_offers.project_id, projectIds))
.orderBy(asc(project_offers.created_at));
return rows.map((r) => ({
offer_id: r.offer_id,
project_id: r.project_id,
project_name: projectNameMap.get(r.project_id) ?? "—",
public_name: r.public_name,
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
}));
}