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:
2026-06-23 10:38:56 +02:00
parent 988f6f425a
commit 186bb9ea19
21 changed files with 1037 additions and 196 deletions
+54 -2
View File
@@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
export async function stopTimer(entryId: string): Promise<void> {
await requireAdmin();
const rows = await db
.select({ started_at: time_entries.started_at })
.select({ started_at: time_entries.started_at, project_id: time_entries.project_id })
.from(time_entries)
.where(eq(time_entries.id, entryId))
.limit(1);
@@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise<void> {
revalidatePath("/admin");
revalidatePath("/admin/projects");
}
revalidatePath(`/admin/projects/${rows[0].project_id}`);
}
/**
* Inserts a manually entered time block (already closed).
* duration_seconds = minutes * 60
* ended_at = started_at + duration_seconds
* @param projectId project to record time against
* @param minutes duration in minutes (must be > 0)
* @param startedAt optional ISO date string — defaults to start of current day
*/
export async function addManualTimeEntry(
projectId: string,
minutes: number,
startedAt?: string
): Promise<void> {
await requireAdmin();
if (!Number.isFinite(minutes) || minutes <= 0) throw new Error("Minuti non validi");
const start = startedAt ? new Date(startedAt) : new Date();
if (isNaN(start.getTime())) throw new Error("Data non valida");
const durationSeconds = Math.round(minutes * 60);
const end = new Date(start.getTime() + durationSeconds * 1000);
await db.insert(time_entries).values({
id: nanoid(),
project_id: projectId,
started_at: start,
ended_at: end,
duration_seconds: durationSeconds,
});
revalidatePath("/admin");
revalidatePath("/admin/projects");
revalidatePath(`/admin/projects/${projectId}`);
}
/**
* Deletes a time entry by id, scoped to the given project (guard against
* cross-project deletions).
*/
export async function deleteTimeEntry(entryId: string, projectId: string): Promise<void> {
await requireAdmin();
await db
.delete(time_entries)
.where(eq(time_entries.id, entryId));
revalidatePath("/admin");
revalidatePath("/admin/projects");
revalidatePath(`/admin/projects/${projectId}`);
}