186bb9ea19
- 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>
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth";
|
|
import { db } from "@/db";
|
|
|
|
async function requireAdmin() {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session) throw new Error("Non autorizzato");
|
|
}
|
|
import { time_entries, projects } from "@/db/schema";
|
|
import { eq, isNull, asc } from "drizzle-orm";
|
|
import { nanoid } from "nanoid";
|
|
|
|
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
|
await requireAdmin();
|
|
// Stop any currently running session before starting a new one
|
|
const running = await db
|
|
.select({ id: time_entries.id })
|
|
.from(time_entries)
|
|
.where(isNull(time_entries.ended_at));
|
|
|
|
for (const r of running) {
|
|
const now = new Date();
|
|
const entry = await db
|
|
.select({ started_at: time_entries.started_at })
|
|
.from(time_entries)
|
|
.where(eq(time_entries.id, r.id))
|
|
.limit(1);
|
|
if (entry[0]) {
|
|
const secs = Math.round((now.getTime() - new Date(entry[0].started_at).getTime()) / 1000);
|
|
await db
|
|
.update(time_entries)
|
|
.set({ ended_at: now, duration_seconds: secs })
|
|
.where(eq(time_entries.id, r.id));
|
|
}
|
|
}
|
|
|
|
const id = nanoid();
|
|
await db.insert(time_entries).values({ id, project_id: projectId });
|
|
revalidatePath("/admin");
|
|
revalidatePath("/admin/projects");
|
|
revalidatePath(`/admin/projects/${projectId}`);
|
|
return { entryId: id };
|
|
}
|
|
|
|
export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> {
|
|
await requireAdmin();
|
|
const projectRows = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.client_id, clientId))
|
|
.orderBy(asc(projects.created_at))
|
|
.limit(1);
|
|
const projectId = projectRows[0]?.id;
|
|
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
|
return startTimer(projectId);
|
|
}
|
|
|
|
export async function stopTimer(entryId: string): Promise<void> {
|
|
await requireAdmin();
|
|
const rows = await db
|
|
.select({ started_at: time_entries.started_at, project_id: time_entries.project_id })
|
|
.from(time_entries)
|
|
.where(eq(time_entries.id, entryId))
|
|
.limit(1);
|
|
|
|
if (!rows[0]) return;
|
|
|
|
const now = new Date();
|
|
const secs = Math.round((now.getTime() - new Date(rows[0].started_at).getTime()) / 1000);
|
|
await db
|
|
.update(time_entries)
|
|
.set({ ended_at: now, duration_seconds: secs })
|
|
.where(eq(time_entries.id, entryId));
|
|
|
|
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}`);
|
|
}
|
|
|