63c9f750df
- schema.ts: add projects table, settings kv table, slug on clients; migrate 6 FK from client_id to project_id (phases, payments, documents, notes, time_entries, quote_items); update all relations and TypeScript types - admin-queries.ts: fix getAllClientsWithPayments + getClientFullDetail to aggregate through projects; add getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects, ClientWithProjects type - settings.ts: new file — getSetting, updateSetting, getTargetHourlyRate, SETTINGS_KEYS - Fix all downstream files: actions.ts, quote-actions.ts, new/actions.ts, timer-actions.ts, approve/route.ts, comment/route.ts, TimerCell.tsx, analytics-queries.ts, client-view.ts, seed.ts - DB migration applied to Coolify Postgres (all test data cleared, schema rebuilt) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
9.1 KiB
TypeScript
254 lines
9.1 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { db } from "@/db";
|
|
import {
|
|
phases,
|
|
tasks,
|
|
deliverables,
|
|
documents,
|
|
payments,
|
|
clients,
|
|
projects,
|
|
comments,
|
|
} from "@/db/schema";
|
|
import { eq, asc, inArray } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
|
|
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
|
|
|
|
const clientSchema = z.object({
|
|
name: z.string().min(1, "Nome richiesto"),
|
|
brand_name: z.string().min(1, "Brand name richiesto"),
|
|
brief: z.string(),
|
|
});
|
|
|
|
export async function updateClient(clientId: string, formData: FormData) {
|
|
const parsed = clientSchema.safeParse({
|
|
name: formData.get("name"),
|
|
brand_name: formData.get("brand_name"),
|
|
brief: formData.get("brief") ?? "",
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
|
|
revalidatePath(`/admin/clients/${clientId}`);
|
|
revalidatePath("/admin");
|
|
}
|
|
|
|
export async function deleteClient(clientId: string) {
|
|
await db.delete(clients).where(eq(clients.id, clientId));
|
|
revalidatePath("/admin");
|
|
redirect("/admin");
|
|
}
|
|
|
|
export async function setClientArchived(clientId: string, archived: boolean) {
|
|
await db.update(clients).set({ archived }).where(eq(clients.id, clientId));
|
|
revalidatePath(`/admin/clients/${clientId}`);
|
|
revalidatePath("/admin");
|
|
}
|
|
|
|
// ── PHASES ────────────────────────────────────────────────────────────────────
|
|
|
|
async function getDefaultProjectId(clientId: string): Promise<string | null> {
|
|
const rows = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.client_id, clientId))
|
|
.orderBy(asc(projects.created_at))
|
|
.limit(1);
|
|
return rows[0]?.id ?? null;
|
|
}
|
|
|
|
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 projectId = await getDefaultProjectId(clientId);
|
|
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
|
|
|
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(`/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);
|
|
|
|
const projectId = await getDefaultProjectId(clientId);
|
|
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
|
|
|
await db.insert(documents).values({ project_id: projectId, ...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));
|
|
|
|
// Get all projects for this client, then update their payment stubs
|
|
const projectRows = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.client_id, clientId));
|
|
|
|
if (projectRows.length > 0) {
|
|
const projectIds = projectRows.map((p) => p.id);
|
|
const half = (val / 2).toFixed(2);
|
|
const paymentsRows = await db
|
|
.select()
|
|
.from(payments)
|
|
.where(inArray(payments.project_id, projectIds));
|
|
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}`);
|
|
} |