ae355c33a6
- Client detail: category + tier badges on active offers - Dashboard: remove top KPI cards + recent activity; fold current KPIs into bottom MetricCard style; add 12-month income forecast chart - Forecast query: branch by offer_type (retainer = monthly fee, una_tantum = spread over duration), filter archived projects - Payments: set the month a payment was collected (paid_at) from PaymentsTab - Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
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 {
|
|
phases,
|
|
tasks,
|
|
deliverables,
|
|
documents,
|
|
payments,
|
|
clients,
|
|
projects,
|
|
comments,
|
|
} from "@/db/schema";
|
|
import { eq, asc, inArray } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
|
|
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
|
|
// Both clientId and projectId are passed as "clientId" by tab components.
|
|
// This helper resolves the correct projectId and revalidation path for either.
|
|
|
|
async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> {
|
|
const asProject = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.id, id))
|
|
.limit(1);
|
|
|
|
if (asProject[0]) {
|
|
return { projectId: id, path: `/admin/projects/${id}` };
|
|
}
|
|
|
|
const asClient = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.client_id, id))
|
|
.orderBy(asc(projects.created_at))
|
|
.limit(1);
|
|
|
|
return { projectId: asClient[0]?.id ?? null, path: `/admin/clients/${id}` };
|
|
}
|
|
|
|
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
|
|
|
|
const emptyToNull = (v: unknown) => {
|
|
const s = typeof v === "string" ? v.trim() : "";
|
|
return s === "" ? null : s;
|
|
};
|
|
|
|
const clientSchema = z.object({
|
|
name: z.string().min(1, "Nome richiesto"),
|
|
brand_name: z.string().min(1, "Brand name richiesto"),
|
|
brief: z.string(),
|
|
email: z.string().optional().transform(emptyToNull),
|
|
phone: z.string().optional().transform(emptyToNull),
|
|
slug: z
|
|
.string()
|
|
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
|
|
.optional()
|
|
.or(z.literal(""))
|
|
.transform((v) => (v === "" || v === undefined ? null : v)),
|
|
});
|
|
|
|
export async function updateClient(clientId: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const parsed = clientSchema.safeParse({
|
|
name: formData.get("name"),
|
|
brand_name: formData.get("brand_name"),
|
|
brief: formData.get("brief") ?? "",
|
|
email: formData.get("email") ?? "",
|
|
phone: formData.get("phone") ?? "",
|
|
slug: formData.get("slug") ?? "",
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
try {
|
|
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (msg.includes("unique") || msg.includes("duplicate")) {
|
|
redirect(`/admin/clients/${clientId}/edit?error=slug_taken`);
|
|
}
|
|
throw e;
|
|
}
|
|
revalidatePath(`/admin/clients/${clientId}`);
|
|
revalidatePath("/admin");
|
|
redirect(`/admin/clients/${clientId}`);
|
|
}
|
|
|
|
export async function deleteClient(clientId: string) {
|
|
await requireAdmin();
|
|
await db.delete(clients).where(eq(clients.id, clientId));
|
|
revalidatePath("/admin");
|
|
redirect("/admin");
|
|
}
|
|
|
|
export async function setClientArchived(clientId: string, archived: boolean) {
|
|
await requireAdmin();
|
|
await db.update(clients).set({ archived }).where(eq(clients.id, clientId));
|
|
revalidatePath(`/admin/clients/${clientId}`);
|
|
revalidatePath("/admin");
|
|
}
|
|
|
|
// ── PHASES ────────────────────────────────────────────────────────────────────
|
|
|
|
export async function addPhase(id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const title = (formData.get("title") as string)?.trim();
|
|
if (!title) throw new Error("Titolo fase richiesto");
|
|
|
|
const { projectId, path } = await resolveEntity(id);
|
|
if (!projectId) throw new Error("Nessun progetto trovato");
|
|
|
|
const existingPhases = await db
|
|
.select({ sort_order: phases.sort_order })
|
|
.from(phases)
|
|
.where(eq(phases.project_id, projectId));
|
|
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
|
|
|
|
await db.insert(phases).values({
|
|
project_id: projectId,
|
|
title,
|
|
sort_order: maxOrder + 1,
|
|
status: "upcoming",
|
|
});
|
|
revalidatePath(path);
|
|
}
|
|
|
|
export async function updatePhaseStatus(phaseId: string, id: string, status: string) {
|
|
await requireAdmin();
|
|
const allowed = ["upcoming", "active", "done"];
|
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
|
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
export async function deletePhase(phaseId: string, id: string) {
|
|
await requireAdmin();
|
|
// FK cascade on tasks and deliverables handles child rows
|
|
await db.delete(phases).where(eq(phases.id, phaseId));
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function addTask(phaseId: string, id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const title = (formData.get("title") as string)?.trim();
|
|
if (!title) throw new Error("Titolo task richiesto");
|
|
|
|
const existingTasks = await db
|
|
.select({ sort_order: tasks.sort_order })
|
|
.from(tasks)
|
|
.where(eq(tasks.phase_id, phaseId));
|
|
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1);
|
|
|
|
await db.insert(tasks).values({
|
|
phase_id: phaseId,
|
|
title,
|
|
description: (formData.get("description") as string)?.trim() || null,
|
|
sort_order: maxOrder + 1,
|
|
status: "todo",
|
|
});
|
|
// Cascade: a new todo task keeps phase active/upcoming — recompute to be safe
|
|
await recomputePhaseStatus(phaseId);
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
|
// Recomputes phase status from its tasks:
|
|
// all done → done
|
|
// any in_progress or done (but not all done) → active
|
|
// all todo (or no tasks) → upcoming
|
|
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
|
const phaseTasks = await db
|
|
.select({ status: tasks.status })
|
|
.from(tasks)
|
|
.where(eq(tasks.phase_id, phaseId));
|
|
|
|
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
|
if (phaseTasks.length > 0) {
|
|
const allDone = phaseTasks.every((t) => t.status === "done");
|
|
const anyActive = phaseTasks.some(
|
|
(t) => t.status === "in_progress" || t.status === "done"
|
|
);
|
|
if (allDone) newStatus = "done";
|
|
else if (anyActive) newStatus = "active";
|
|
}
|
|
await db.update(phases).set({ status: newStatus }).where(eq(phases.id, phaseId));
|
|
}
|
|
|
|
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
|
await requireAdmin();
|
|
const allowed = ["todo", "in_progress", "done"];
|
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
|
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
|
|
|
// Cascade: recompute parent phase status from all its tasks
|
|
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
|
if (taskRow[0]) await recomputePhaseStatus(taskRow[0].phase_id);
|
|
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
export async function deleteTask(taskId: string, id: string) {
|
|
await requireAdmin();
|
|
// Fetch phase_id before deletion so we can recompute phase status after
|
|
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
|
const phaseId = taskRow[0]?.phase_id;
|
|
await db.delete(tasks).where(eq(tasks.id, taskId));
|
|
if (phaseId) await recomputePhaseStatus(phaseId);
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
|
|
|
|
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const title = (formData.get("title") as string)?.trim();
|
|
const url = (formData.get("url") as string)?.trim() || null;
|
|
if (!title) throw new Error("Titolo deliverable richiesto");
|
|
await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" });
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// ── DOCUMENTS ─────────────────────────────────────────────────────────────────
|
|
|
|
const docSchema = z.object({
|
|
label: z.string().min(1, "Etichetta richiesta"),
|
|
url: z.string().url("URL non valido"),
|
|
});
|
|
|
|
export async function addDocument(id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const parsed = docSchema.safeParse({
|
|
label: formData.get("label"),
|
|
url: formData.get("url"),
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
|
|
const { projectId, path } = await resolveEntity(id);
|
|
if (!projectId) throw new Error("Nessun progetto trovato");
|
|
|
|
await db.insert(documents).values({ project_id: projectId, ...parsed.data });
|
|
revalidatePath(path);
|
|
}
|
|
|
|
export async function updateDocument(documentId: string, id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const parsed = docSchema.safeParse({
|
|
label: formData.get("label"),
|
|
url: formData.get("url"),
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
await db.update(documents).set(parsed.data).where(eq(documents.id, documentId));
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
export async function deleteDocument(documentId: string, id: string) {
|
|
await requireAdmin();
|
|
await db.delete(documents).where(eq(documents.id, documentId));
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// ── PAYMENTS ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function updatePaymentStatus(paymentId: string, id: string, status: string) {
|
|
await requireAdmin();
|
|
const allowed = ["da_saldare", "inviata", "saldato"];
|
|
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
|
const paid_at = status === "saldato" ? new Date() : null;
|
|
await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId));
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM").
|
|
// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e
|
|
// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics.
|
|
export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) {
|
|
await requireAdmin();
|
|
const m = /^(\d{4})-(\d{2})$/.exec(monthStr);
|
|
if (!m) throw new Error("Mese non valido");
|
|
const year = parseInt(m[1], 10);
|
|
const month = parseInt(m[2], 10);
|
|
if (month < 1 || month > 12) throw new Error("Mese non valido");
|
|
const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
|
|
await db
|
|
.update(payments)
|
|
.set({ paid_at, status: "saldato" })
|
|
.where(eq(payments.id, paymentId));
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
}
|
|
|
|
// Rescales payment amounts when the total changes.
|
|
// If the payment has a `percent` field, use it (new plan rows).
|
|
// Legacy rows (percent null) fall back to equal split across all rows.
|
|
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
|
|
const projectPayments = await db
|
|
.select({ id: payments.id, percent: payments.percent })
|
|
.from(payments)
|
|
.where(eq(payments.project_id, projectId));
|
|
|
|
if (projectPayments.length === 0) return;
|
|
|
|
const hasPercent = projectPayments.some((p) => p.percent !== null);
|
|
|
|
if (hasPercent) {
|
|
// New plan: rescale each row by its stored percent
|
|
for (const p of projectPayments) {
|
|
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
|
|
const newAmount = ((newTotal * pct) / 100).toFixed(2);
|
|
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
|
|
}
|
|
} else {
|
|
// Legacy: equal split across all rows (backward compat)
|
|
const share = (newTotal / projectPayments.length).toFixed(2);
|
|
for (const p of projectPayments) {
|
|
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function updateAcceptedTotal(id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const raw = (formData.get("accepted_total") as string)?.trim();
|
|
const val = parseFloat(raw);
|
|
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
|
|
|
const asProject = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.id, id))
|
|
.limit(1);
|
|
|
|
if (asProject[0]) {
|
|
// Project context: update projects.accepted_total + rescale this project's payments
|
|
await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id));
|
|
await rescalePayments(id, val);
|
|
revalidatePath(`/admin/projects/${id}`);
|
|
} else {
|
|
// Client context: update clients.accepted_total + rescale all project payments
|
|
await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id));
|
|
const projectRows = await db.select({ id: projects.id })
|
|
.from(projects).where(eq(projects.client_id, id));
|
|
for (const proj of projectRows) {
|
|
await rescalePayments(proj.id, val);
|
|
}
|
|
revalidatePath(`/admin/clients/${id}`);
|
|
}
|
|
}
|
|
|
|
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
|
|
|
|
export async function postAdminComment(id: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const entity = formData.get("entity") as string;
|
|
const body = (formData.get("body") as string)?.trim();
|
|
if (!body || !entity) throw new Error("Dati mancanti");
|
|
const [entity_type, entity_id] = entity.split(":");
|
|
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
|
const allowedTypes = ["task", "deliverable", "phase", "general"];
|
|
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
|
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
|
const { path } = await resolveEntity(id);
|
|
revalidatePath(path);
|
|
} |