e10d1f70cb
- 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>
455 lines
14 KiB
TypeScript
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,
|
|
};
|
|
} |