Files
clienthub/src/lib/client-view.ts
T
simone e10d1f70cb fix: cumulative price from offer_tier_services, offers accordion, retainer phase placeholder
- client-view.ts: read cumulative_price + services list from offer_tier_services→services
  (Phase 12 catalog); legacy fallback to offer_micro_services→offer_services when no
  new-style rows exist. Extend activeOffers type with services[] in both ProjectView and ClientView.
- OffersSection.tsx: extract OfferCard client component with useState accordion;
  hide "Valore incluso" row when price is 0; add "Cosa è compreso" chevron toggle
  listing included services (name + description when present).
- client-dashboard.tsx: hide progress bar and PhaseViewToggle when hasRetainer;
  render polished empty-state placeholder with RefreshCw icon instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:53:25 +02:00

455 lines
14 KiB
TypeScript

import { eq, inArray, asc, desc, sql } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
/**
* ClientView: Legacy shape used by ClientDashboard component.
* Kept for backward compatibility — new code uses ProjectView.
* Deliberately excludes: quote_items, service_catalog, service prices, payment amounts.
*/
export interface ClientView {
client: {
id: string;
name: string;
brand_name: string;
brief: string;
accepted_total: string;
};
phases: Array<{
id: string;
title: string;
status: "upcoming" | "active" | "done";
sort_order: number;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: "todo" | "in_progress" | "done";
sort_order: number;
deliverables: Array<{
id: string;
title: string;
url: string | null;
status: "pending" | "submitted" | "approved";
approved_at: string | null;
}>;
}>;
progress_pct: number;
}>;
payments: Array<{
id: string;
label: string;
status: "da_saldare" | "inviata" | "saldato";
}>;
documents: Array<{
id: string;
label: string;
url: string;
}>;
notes: Array<{
id: string;
body: string;
created_at: string;
}>;
global_progress_pct: number;
activeOffers?: Array<{
id: string;
public_name: string;
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string;
accepted_total: string | null;
services: Array<{ name: string; description: string | null }>;
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: string;
}>;
}
export interface ProjectView {
project: {
id: string;
name: string;
client_id: string;
accepted_total: string;
};
phases: Array<{
id: string;
title: string;
status: string;
sort_order: number;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: string;
sort_order: number;
deliverables: Array<{
id: string;
title: string;
url: string | null;
status: string;
approved_at: Date | null;
task_id: string;
}>;
}>;
progress_pct: number;
}>;
payments: Array<{
id: string;
label: string;
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
status: string;
}>;
documents: Array<{
id: string;
label: string;
url: string;
created_at: Date;
}>;
notes: Array<{
id: string;
body: string;
created_at: Date;
}>;
comments: Array<{
id: string;
entity_type: string;
entity_id: string;
author: string;
body: string;
created_at: Date;
}>;
global_progress_pct: number;
activeOffers?: Array<{
id: string;
public_name: string; // offer_micros.public_name — NEVER internal_name
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
accepted_total: string | null;
services: Array<{ name: string; description: string | null }>; // included services list
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
}>;
}
export interface ClientProjectSummary {
client: {
id: string;
name: string;
brand_name: string;
token: string;
slug: string | null;
};
projects: Array<{
id: string;
name: string;
archived: boolean;
}>;
}
/**
* Resolves a token-or-slug to a client and returns the client's active projects.
* Lookup order: slug first, then token — mirrors middleware order (D-06).
*/
export async function getClientWithProjectsByToken(
tokenOrSlug: string
): Promise<ClientProjectSummary | null> {
try {
// Try slug first
let clientRows = await db
.select({
id: clients.id,
name: clients.name,
brand_name: clients.brand_name,
token: clients.token,
slug: clients.slug,
})
.from(clients)
.where(eq(clients.slug, tokenOrSlug))
.limit(1);
// Fall back to token
if (clientRows.length === 0) {
clientRows = await db
.select({
id: clients.id,
name: clients.name,
brand_name: clients.brand_name,
token: clients.token,
slug: clients.slug,
})
.from(clients)
.where(eq(clients.token, tokenOrSlug))
.limit(1);
}
if (clientRows.length === 0) return null;
const client = clientRows[0];
const projectRows = await db
.select({ id: projects.id, name: projects.name, archived: projects.archived })
.from(projects)
.where(eq(projects.client_id, client.id))
.orderBy(asc(projects.created_at));
const activeProjects = projectRows.filter((p) => !p.archived);
return { client, projects: activeProjects };
} catch (err) {
console.error("[client-view] getClientWithProjectsByToken error:", err);
return null;
}
}
/**
* Returns full project data for the client dashboard.
* CRITICAL: Does NOT include quote_items — client API never exposes them (CLAUDE.md constraint).
* payments include status only, NOT amount or unit_price (DASH-07).
*/
export async function getProjectView(projectId: string): Promise<ProjectView | null> {
const projectRows = await db
.select({
id: projects.id,
name: projects.name,
client_id: projects.client_id,
accepted_total: projects.accepted_total,
})
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (projectRows.length === 0) return null;
const project = projectRows[0];
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.project_id, projectId))
.orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id);
const tasksRows =
phaseIds.length === 0
? []
: await db
.select()
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds))
.orderBy(asc(tasks.sort_order));
const taskIds = tasksRows.map((t) => t.id);
// approved_at included — immutable audit trail (CLAUDE.md constraint LOCKED)
const deliverablesRows =
taskIds.length === 0
? []
: await db
.select({
id: deliverables.id,
title: deliverables.title,
url: deliverables.url,
status: deliverables.status,
approved_at: deliverables.approved_at,
task_id: deliverables.task_id,
})
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
// Payments — status only, NO amount (DASH-07 / CLAUDE.md)
const paymentsRows = await db
.select({
id: payments.id,
label: payments.label,
status: payments.status,
// amount intentionally excluded — client API never exposes payment amounts
})
.from(payments)
.where(eq(payments.project_id, projectId));
const documentsRows = await db
.select({
id: documents.id,
label: documents.label,
url: documents.url,
created_at: documents.created_at,
})
.from(documents)
.where(eq(documents.project_id, projectId))
.orderBy(asc(documents.created_at));
const notesRows = await db
.select({ id: notes.id, body: notes.body, created_at: notes.created_at })
.from(notes)
.where(eq(notes.project_id, projectId))
.orderBy(asc(notes.created_at));
// Fetch active offers for this project (client-safe fields only — never internal_name)
const projectOfferRows = await db
.select({
id: project_offers.id,
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
offer_type: offer_macros.offer_type, // una_tantum | retainer
accepted_total: project_offers.accepted_total,
micro_id: project_offers.micro_id,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, projectId));
// Cumulative price + service list per micro.
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
const microIds = projectOfferRows.map((o) => o.micro_id);
// New-style: services from offer_tier_services joined to unified services catalog
const tierServiceRows = microIds.length === 0 ? [] : await db
.select({
tier_id: offer_tier_services.tier_id,
name: services.name,
description: services.description,
unit_price: services.unit_price,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, microIds));
// Group by tier_id for quick lookup
const tierServicesMap = new Map<string, Array<{ name: string; description: string | null; unit_price: string }>>();
for (const row of tierServiceRows) {
const existing = tierServicesMap.get(row.tier_id) ?? [];
existing.push({ name: row.name, description: row.description, unit_price: row.unit_price });
tierServicesMap.set(row.tier_id, existing);
}
// Determine which micros have NO entries in the new table → need legacy fallback
const microsNeedingLegacy = microIds.filter((id) => !tierServicesMap.has(id));
// Legacy fallback: offer_micro_services → offer_services (for old offers with no tier_services rows)
const legacyPriceRows = microsNeedingLegacy.length === 0 ? [] : await db
.select({
micro_id: offer_micro_services.micro_id,
name: offer_services.name,
price: offer_services.price,
})
.from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microsNeedingLegacy));
const legacyServicesMap = new Map<string, Array<{ name: string; description: string | null; price: string }>>();
for (const row of legacyPriceRows) {
const existing = legacyServicesMap.get(row.micro_id) ?? [];
existing.push({ name: row.name, description: null, price: row.price });
legacyServicesMap.set(row.micro_id, existing);
}
const activeOffers = projectOfferRows.map((o) => {
const newStyleServices = tierServicesMap.get(o.micro_id);
const legacyServices = legacyServicesMap.get(o.micro_id);
let cumulative_price: string;
let servicesList: Array<{ name: string; description: string | null }>;
if (newStyleServices && newStyleServices.length > 0) {
// Use new catalog prices
const total = newStyleServices.reduce((sum, s) => sum + parseFloat(s.unit_price ?? "0"), 0);
cumulative_price = total.toFixed(2);
servicesList = newStyleServices.map((s) => ({ name: s.name, description: s.description }));
} else if (legacyServices && legacyServices.length > 0) {
// Legacy fallback
const total = legacyServices.reduce((sum, s) => sum + parseFloat(s.price ?? "0"), 0);
cumulative_price = total.toFixed(2);
servicesList = legacyServices.map((s) => ({ name: s.name, description: null }));
} else {
cumulative_price = "0";
servicesList = [];
}
return {
id: o.id,
public_name: o.public_name,
offer_name: o.offer_name,
offer_type: o.offer_type,
cumulative_price,
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
services: servicesList,
};
});
// Transcripts linked to the project's client (post lead→client conversion)
const transcriptsRows = await db
.select({
id: clientTranscripts.id,
title: clientTranscripts.title,
call_date: clientTranscripts.call_date,
content: clientTranscripts.content,
created_at: clientTranscripts.created_at,
})
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, project.client_id))
.orderBy(desc(clientTranscripts.call_date));
// Comments scoped to: general (client_id), phases, tasks, and deliverables of this project
const allEntityIds = [
project.client_id, // general messages entity_id = client_id
...phaseIds, // phase-tagged messages
...taskIds,
...deliverablesRows.map((d) => d.id),
];
const commentsRows =
allEntityIds.length === 0
? []
: await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const phasesWithTasks = phasesRows.map((phase) => {
const phaseTasks = tasksRows
.filter((t) => t.phase_id === phase.id)
.map((task) => ({
...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
}));
const doneCount = phaseTasks.filter((t) => t.status === "done").length;
const progress_pct =
phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0;
return { ...phase, tasks: phaseTasks, progress_pct };
});
const doneTasks = tasksRows.filter((t) => t.status === "done").length;
const global_progress_pct =
tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0;
return {
project: {
id: project.id,
name: project.name,
client_id: project.client_id,
accepted_total: project.accepted_total ?? "0",
},
phases: phasesWithTasks,
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
global_progress_pct,
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
transcripts: transcriptsRows,
};
}