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>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
"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<void> {
|
||
await requireAdmin();
|
||
await db.update(projects).set({ archived: true }).where(eq(projects.id, projectId));
|
||
revalidatePath("/admin/projects");
|
||
}
|
||
|
||
export async function unarchiveProject(projectId: string): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
await requireAdmin();
|
||
|
||
type PaymentDef = { label: string; percent: number };
|
||
const plans: Record<string, PaymentDef[]> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, number>();
|
||
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}`);
|
||
} |