feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte attive (con override) + selettore schema rate 1/2/3 step; nuova colonna payments.percent per rescalare gli importi preservando label/stato - Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata - Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili - Transcript del cliente visibili in tab Documenti admin e dashboard pubblica - Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry - CLAUDE.md: procedura Deploy & DB Access; push su main automatico Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
quotes,
|
||||
leads,
|
||||
tags,
|
||||
clientTranscripts,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||
import { getPool } from "@/lib/taxonomy";
|
||||
@@ -551,6 +552,8 @@ export type ProjectFullDetail = {
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
projectOffers: ProjectOfferWithMicro[];
|
||||
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
||||
offersAcceptedTotal: number;
|
||||
availableMicros: Array<{
|
||||
id: string;
|
||||
internal_name: string;
|
||||
@@ -563,6 +566,13 @@ export type ProjectFullDetail = {
|
||||
macro_category: string | null;
|
||||
macro_offer_type: string;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
||||
@@ -609,7 +619,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.from(deliverables)
|
||||
.where(inArray(deliverables.task_id, taskIds));
|
||||
|
||||
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows] =
|
||||
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
||||
await Promise.all([
|
||||
db.select().from(payments).where(eq(payments.project_id, id)),
|
||||
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
||||
@@ -683,6 +693,18 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
asc(offer_micros.tier_letter),
|
||||
asc(offer_micros.id)
|
||||
),
|
||||
// Query C: transcripts linked to the project's client
|
||||
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)),
|
||||
]);
|
||||
|
||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
@@ -715,6 +737,11 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
return true;
|
||||
});
|
||||
|
||||
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
|
||||
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
project: { ...project, client } as ProjectFullDetail["project"],
|
||||
phases: phasesWithTasks,
|
||||
@@ -728,10 +755,40 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||
offersAcceptedTotal,
|
||||
availableMicros: dedupedMicros,
|
||||
transcripts: transcriptsRows,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Recent time entries — used by TimerTab for the manual-entry recent list ───
|
||||
|
||||
export type RecentTimeEntry = {
|
||||
id: string;
|
||||
started_at: Date;
|
||||
ended_at: Date | null;
|
||||
duration_seconds: number | null;
|
||||
};
|
||||
|
||||
export async function getRecentTimeEntries(
|
||||
projectId: string,
|
||||
limit = 5
|
||||
): Promise<RecentTimeEntry[]> {
|
||||
// sql<boolean>`ended_at IS NOT NULL` used to filter closed entries
|
||||
// (Drizzle's isNotNull is available in v0.28+ but we use sql for safety)
|
||||
return db
|
||||
.select({
|
||||
id: time_entries.id,
|
||||
started_at: time_entries.started_at,
|
||||
ended_at: time_entries.ended_at,
|
||||
duration_seconds: time_entries.duration_seconds,
|
||||
})
|
||||
.from(time_entries)
|
||||
.where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`))
|
||||
.orderBy(desc(time_entries.started_at))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
// ── ClientWithProjects — used by client detail showing project cards ──────────
|
||||
|
||||
export type ClientWithProjects = Client & {
|
||||
|
||||
+30
-2
@@ -1,6 +1,6 @@
|
||||
import { eq, inArray, asc, sql } from "drizzle-orm";
|
||||
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 } from "@/db/schema";
|
||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, clientTranscripts } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* ClientView: Legacy shape used by ClientDashboard component.
|
||||
@@ -58,6 +58,13 @@ export interface ClientView {
|
||||
cumulative_price: string;
|
||||
accepted_total: string | null;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProjectView {
|
||||
@@ -121,6 +128,13 @@ export interface ProjectView {
|
||||
cumulative_price: string; // sum of assigned offer_services.price
|
||||
accepted_total: string | null;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClientProjectSummary {
|
||||
@@ -308,6 +322,19 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
||||
}));
|
||||
|
||||
// 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 tasks and deliverables of this project
|
||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
const commentsRows =
|
||||
@@ -352,5 +379,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
comments: commentsRows,
|
||||
global_progress_pct,
|
||||
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
||||
transcripts: transcriptsRows,
|
||||
};
|
||||
}
|
||||
@@ -219,3 +219,13 @@ export async function getTranscripts(leadId: string) {
|
||||
.where(eq(clientTranscripts.lead_id, leadId))
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
|
||||
// Get transcripts linked to a client (post lead→client conversion).
|
||||
// Returns all fields — used by admin DocumentsTab and public TranscriptsSection.
|
||||
export async function getTranscriptsByClientId(clientId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(clientTranscripts)
|
||||
.where(eq(clientTranscripts.client_id, clientId))
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user