diff --git a/src/app/admin/projects/project-actions.ts b/src/app/admin/projects/project-actions.ts index 70f62e8..f030cc9 100644 --- a/src/app/admin/projects/project-actions.ts +++ b/src/app/admin/projects/project-actions.ts @@ -4,8 +4,9 @@ import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { db } from "@/db"; -import { projects, clients, payments } from "@/db/schema"; +import { projects, clients, payments, project_offers } from "@/db/schema"; import { eq } from "drizzle-orm"; +import { z } from "zod"; import { nanoid } from "nanoid"; async function requireAdmin() { @@ -107,4 +108,55 @@ export async function splitPayment( }); revalidatePath(`/admin/projects/${projectId}`); +} + +// ── Offer assignment actions ────────────────────────────────────────────────── + +const assignOfferSchema = z.object({ + project_id: z.string().min(1), + micro_id: z.string().min(1, "Seleziona una micro-offerta"), + accepted_total: z.coerce.number().min(0).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, + }); + 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, + }); + revalidatePath(`/admin/projects/${parsed.data.project_id}`); + revalidatePath("/admin/forecast"); +} + +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}`); + revalidatePath("/admin/forecast"); +} + +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}`); + revalidatePath("/admin/forecast"); } \ No newline at end of file diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 11f8b65..9e697fd 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -13,6 +13,8 @@ import { quote_items, service_catalog, settings, + offer_micros, + project_offers, } from "@/db/schema"; import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; import type { @@ -26,6 +28,8 @@ import type { Note, Comment, ServiceCatalog, + OfferMicro, + ProjectOffer, } from "@/db/schema"; // ── ClientWithPayments — used by /admin clients list ───────────────────────── @@ -182,6 +186,20 @@ export async function getClientById(id: string) { return rows[0] ?? null; } +// ── ProjectOfferWithMicro — used by OffersTab ──────────────────────────────── + +export type ProjectOfferWithMicro = { + id: string; + project_id: string; + micro_id: string; + micro_internal_name: string; + micro_public_name: string; + micro_duration_months: number; + start_date: Date; + accepted_total: string | null; + created_at: Date; +}; + // ── ClientFullDetail — used by /admin/clients/[id] workspace ───────────────── // quote_items NEVER exposed via client API — admin workspace query only @@ -442,6 +460,8 @@ export type ProjectFullDetail = { activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; totalTrackedSeconds: number; + projectOffers: ProjectOfferWithMicro[]; + availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>; }; export async function getProjectFullDetail(id: string): Promise { @@ -488,7 +508,7 @@ export async function getProjectFullDetail(id: string): Promise`coalesce(sum(${time_entries.duration_seconds}), 0)` }) .from(time_entries) .where(eq(time_entries.project_id, id)), + // Query A: project offers for this project joined with micro info + db + .select({ + id: project_offers.id, + project_id: project_offers.project_id, + micro_id: project_offers.micro_id, + micro_internal_name: offer_micros.internal_name, + micro_public_name: offer_micros.public_name, + micro_duration_months: offer_micros.duration_months, + start_date: project_offers.start_date, + accepted_total: project_offers.accepted_total, + created_at: project_offers.created_at, + }) + .from(project_offers) + .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) + .where(eq(project_offers.project_id, id)) + .orderBy(asc(project_offers.created_at)), + // Query B: all active micro-offers (for the assignment dropdown) + db + .select({ + id: offer_micros.id, + internal_name: offer_micros.internal_name, + public_name: offer_micros.public_name, + duration_months: offer_micros.duration_months, + }) + .from(offer_micros) + .orderBy(asc(offer_micros.internal_name)), ]); const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)]; @@ -551,6 +598,8 @@ export async function getProjectFullDetail(id: string): Promise