"use server"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { db } from "@/db"; import { projects, clients, payments, project_offers, offer_tier_services, services, phases, tasks, } from "@/db/schema"; import { eq, asc } from "drizzle-orm"; import { z } from "zod"; import { nanoid } from "nanoid"; async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } export async function createProject(fd: FormData): Promise<{ projectId: string }> { await requireAdmin(); const name = String(fd.get("name") ?? "").trim(); const clientId = String(fd.get("client_id") ?? "").trim(); if (!name) throw new Error("Nome progetto obbligatorio"); if (!clientId) throw new Error("Cliente obbligatorio"); const clientRows = await db .select({ id: clients.id }) .from(clients) .where(eq(clients.id, clientId)) .limit(1); if (clientRows.length === 0) throw new Error("Cliente non trovato"); const id = nanoid(); await db.insert(projects).values({ id, client_id: clientId, name }); revalidatePath("/admin/projects"); revalidatePath(`/admin/clients/${clientId}`); return { projectId: id }; } export async function archiveProject(projectId: string): Promise { await requireAdmin(); await db.update(projects).set({ archived: true }).where(eq(projects.id, projectId)); revalidatePath("/admin/projects"); } export async function unarchiveProject(projectId: string): Promise { await requireAdmin(); await db.update(projects).set({ archived: false }).where(eq(projects.id, projectId)); revalidatePath("/admin/projects"); } export async function updateProjectAcceptedTotal(projectId: string, acceptedTotal: string): Promise { await requireAdmin(); await db.update(projects).set({ accepted_total: acceptedTotal }).where(eq(projects.id, projectId)); revalidatePath(`/admin/projects/${projectId}`); } // Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet. // Kept for backward compatibility; prefer setPaymentPlan for new code. export async function initProjectPayments(projectId: string): Promise { await requireAdmin(); const rows = await db .select({ accepted_total: projects.accepted_total }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); if (!rows[0]) throw new Error("Progetto non trovato"); const total = parseFloat(rows[0].accepted_total ?? "0"); await setPaymentPlan(projectId, "two", total); } // Replaces all payments for a project with a fresh plan at a given total. // Modes: // single → 1 row 100% "Pagamento unico (100%)" // two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)" // three → 3 rows 50%/30%/20% export async function setPaymentPlan( projectId: string, mode: "single" | "two" | "three", total: number ): Promise { await requireAdmin(); type PaymentDef = { label: string; percent: number }; const plans: Record = { single: [{ label: "Pagamento unico (100%)", percent: 100 }], two: [ { label: "Acconto 50% (inizio lavori)", percent: 50 }, { label: "Saldo 50% (alla consegna)", percent: 50 }, ], three: [ { label: "Acconto 50% (inizio lavori)", percent: 50 }, { label: "30% (post revisioni)", percent: 30 }, { label: "Saldo 20% (alla consegna)", percent: 20 }, ], }; const plan = plans[mode]; if (!plan) throw new Error("Modalità pagamento non valida"); // Delete existing payments for this project, then insert new ones. await db.delete(payments).where(eq(payments.project_id, projectId)); await db.insert(payments).values( plan.map((p) => ({ project_id: projectId, label: p.label, percent: p.percent.toFixed(2), amount: ((total * p.percent) / 100).toFixed(2), status: "da_saldare" as const, })) ); revalidatePath(`/admin/projects/${projectId}`); } // Splits one payment into two with custom amounts. The original row keeps // its label (+ " – Rata 1"), a new row is created with the remainder. export async function splitPayment( paymentId: string, projectId: string, firstAmountStr: string ): Promise { await requireAdmin(); const first = parseFloat(firstAmountStr); if (isNaN(first) || first <= 0) throw new Error("Importo non valido"); const rows = await db.select().from(payments).where(eq(payments.id, paymentId)).limit(1); if (!rows[0]) throw new Error("Pagamento non trovato"); const original = rows[0]; const total = parseFloat(original.amount); const second = parseFloat((total - first).toFixed(2)); if (second <= 0) throw new Error("Il primo importo deve essere inferiore al totale"); // Strip any existing " – Rata N" suffix so re-splits stay clean. const baseLabel = original.label.replace(/ – Rata \d+$/, ""); await db .update(payments) .set({ amount: first.toFixed(2), label: `${baseLabel} – Rata 1` }) .where(eq(payments.id, paymentId)); await db.insert(payments).values({ project_id: projectId, label: `${baseLabel} – Rata 2`, amount: second.toFixed(2), status: original.status, }); revalidatePath(`/admin/projects/${projectId}`); } // ── Offer → project phases/tasks import ─────────────────────────────────────── // An offer/tier has no phases of its own; it carries a flat list of services, // and each service has a `fase` (catalog taxonomy). We materialize project // phases by grouping the tier's services by `services.fase` (one phase per // distinct fase, one task per service). Idempotent + mergeable: existing phases // are matched by title (case-insensitive) and reused, duplicate tasks skipped — // so assigning a 2nd offer with shared fasi adds tasks without duplicating. const NO_FASE_LABEL = "Generale"; export async function importOfferIntoProject(projectId: string, microId: string): Promise { await requireAdmin(); // Tier services with their catalog fase, ordered (null fase last, then name). const rows = await db .select({ name: services.name, fase: services.fase }) .from(offer_tier_services) .innerJoin(services, eq(offer_tier_services.service_id, services.id)) .where(eq(offer_tier_services.tier_id, microId)) .orderBy(asc(services.fase), asc(services.name)); if (rows.length === 0) return; // Group service names by fase, preserving first-seen order. const groups: Array<{ fase: string; serviceNames: string[] }> = []; const groupIndex = new Map(); for (const r of rows) { const fase = r.fase?.trim() || NO_FASE_LABEL; let idx = groupIndex.get(fase.toLowerCase()); if (idx === undefined) { idx = groups.length; groupIndex.set(fase.toLowerCase(), idx); groups.push({ fase, serviceNames: [] }); } groups[idx].serviceNames.push(r.name); } // Existing project phases (for merge-by-title) + current max sort_order. const existingPhases = await db .select({ id: phases.id, title: phases.title, sort_order: phases.sort_order }) .from(phases) .where(eq(phases.project_id, projectId)); const phaseByTitle = new Map(existingPhases.map((p) => [p.title.trim().toLowerCase(), p])); let maxPhaseOrder = existingPhases.reduce((m, p) => Math.max(m, p.sort_order), -1); for (const group of groups) { let phaseId: string; const existing = phaseByTitle.get(group.fase.toLowerCase()); if (existing) { phaseId = existing.id; } else { maxPhaseOrder += 1; const [inserted] = await db .insert(phases) .values({ project_id: projectId, title: group.fase, sort_order: maxPhaseOrder, status: "upcoming" }) .returning({ id: phases.id }); phaseId = inserted.id; phaseByTitle.set(group.fase.toLowerCase(), { id: phaseId, title: group.fase, sort_order: maxPhaseOrder }); } // Skip tasks whose title already exists in this phase. const existingTasks = await db .select({ title: tasks.title, sort_order: tasks.sort_order }) .from(tasks) .where(eq(tasks.phase_id, phaseId)); const taskTitles = new Set(existingTasks.map((t) => t.title.trim().toLowerCase())); let maxTaskOrder = existingTasks.reduce((m, t) => Math.max(m, t.sort_order), -1); const toInsert = group.serviceNames .filter((name) => !taskTitles.has(name.trim().toLowerCase())) .map((name) => { taskTitles.add(name.trim().toLowerCase()); maxTaskOrder += 1; return { phase_id: phaseId, title: name, status: "todo", sort_order: maxTaskOrder }; }); if (toInsert.length > 0) await db.insert(tasks).values(toInsert); } revalidatePath(`/admin/projects/${projectId}`); } // ── Offer assignment actions ────────────────────────────────────────────────── const assignOfferSchema = z.object({ project_id: z.string().min(1), micro_id: z.string().min(1, "Seleziona un'offerta"), accepted_total: z.coerce.number().min(0).optional(), import_phases: z.boolean().optional(), }); export async function assignOfferToProject(formData: FormData) { await requireAdmin(); const parsed = assignOfferSchema.safeParse({ project_id: formData.get("project_id"), micro_id: formData.get("micro_id"), accepted_total: formData.get("accepted_total") || undefined, import_phases: formData.get("import_phases") === "on", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(project_offers).values({ project_id: parsed.data.project_id, micro_id: parsed.data.micro_id, accepted_total: parsed.data.accepted_total !== undefined ? parsed.data.accepted_total.toFixed(2) : null, }); // Materialize project phases/tasks from the tier's services grouped by fase. if (parsed.data.import_phases) { await importOfferIntoProject(parsed.data.project_id, parsed.data.micro_id); } revalidatePath(`/admin/projects/${parsed.data.project_id}`); } export async function removeProjectOffer(projectOfferId: string, projectId: string) { await requireAdmin(); await db.delete(project_offers).where(eq(project_offers.id, projectOfferId)); revalidatePath(`/admin/projects/${projectId}`); } export async function updateProjectOfferTotal( projectOfferId: string, projectId: string, accepted_total: string ) { await requireAdmin(); const amount = parseFloat(accepted_total); if (isNaN(amount) || amount < 0) throw new Error("Importo non valido"); await db .update(project_offers) .set({ accepted_total: amount.toFixed(2) }) .where(eq(project_offers.id, projectOfferId)); revalidatePath(`/admin/projects/${projectId}`); }