Files
clienthub/src/app/admin/clients/[id]/actions.ts
T
Simone Cavalli 3582e26970 feat: document edit inline + client dashboard sidebar layout
- actions.ts: add updateDocument server action (label + url, Zod validated)
- DocumentRow: Client Component with hover-reveal edit/remove buttons,
  inline edit form with pre-filled fields and cancel/save
- DocumentsTab: use DocumentRow, remove variant dependency
- client-dashboard: two-column layout (sidebar left on lg+):
  sidebar = payments + documents + notes (sticky top)
  main = brief + phases toggle (timeline / kanban)
  mobile: main first, sidebar below (order-1/order-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:24:49 +02:00

192 lines
7.0 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import {
phases,
tasks,
deliverables,
documents,
payments,
clients,
comments,
} from "@/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
// ── PHASES ────────────────────────────────────────────────────────────────────
export async function addPhase(clientId: string, formData: FormData) {
const title = (formData.get("title") as string)?.trim();
if (!title) throw new Error("Titolo fase richiesto");
const existingPhases = await db
.select({ sort_order: phases.sort_order })
.from(phases)
.where(eq(phases.client_id, clientId));
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
await db.insert(phases).values({
client_id: clientId,
title,
sort_order: maxOrder + 1,
status: "upcoming",
});
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updatePhaseStatus(
phaseId: string,
clientId: string,
status: string
) {
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));
revalidatePath(`/admin/clients/${clientId}`);
}
// ── TASKS ─────────────────────────────────────────────────────────────────────
export async function addTask(
phaseId: string,
clientId: string,
formData: FormData
) {
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",
});
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateTaskStatus(
taskId: string,
clientId: string,
status: string
) {
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));
revalidatePath(`/admin/clients/${clientId}`);
}
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
export async function addDeliverable(
taskId: string,
clientId: string,
formData: FormData
) {
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");
// approved_at is intentionally omitted — immutable constraint: never set by admin here
await db
.insert(deliverables)
.values({ task_id: taskId, title, url, status: "pending" });
revalidatePath(`/admin/clients/${clientId}`);
}
// ── DOCUMENTS ─────────────────────────────────────────────────────────────────
const docSchema = z.object({
label: z.string().min(1, "Etichetta richiesta"),
url: z.string().url("URL non valido"),
});
export async function addDocument(clientId: string, formData: FormData) {
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.insert(documents).values({ client_id: clientId, ...parsed.data });
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateDocument(
documentId: string,
clientId: string,
formData: FormData
) {
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));
revalidatePath(`/admin/clients/${clientId}`);
}
export async function deleteDocument(documentId: string, clientId: string) {
await db.delete(documents).where(eq(documents.id, documentId));
revalidatePath(`/admin/clients/${clientId}`);
}
// ── PAYMENTS ──────────────────────────────────────────────────────────────────
export async function updatePaymentStatus(
paymentId: string,
clientId: string,
status: string
) {
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));
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
const raw = (formData.get("accepted_total") as string)?.trim();
const val = parseFloat(raw);
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
// Update accepted_total on client row — denormalized field, quote_items never exposed
await db
.update(clients)
.set({ accepted_total: val.toFixed(2) })
.where(eq(clients.id, clientId));
// Split evenly between two payment rows (Acconto 50% + Saldo 50%)
const half = (val / 2).toFixed(2);
const paymentsRows = await db
.select()
.from(payments)
.where(eq(payments.client_id, clientId));
for (const p of paymentsRows) {
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
}
revalidatePath(`/admin/clients/${clientId}`);
}
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
export async function postAdminComment(clientId: string, formData: FormData) {
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"];
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
revalidatePath(`/admin/clients/${clientId}`);
}