feat(05-03): extend getProjectFullDetail + add offer assignment server actions

- Add offer_micros, project_offers imports to admin-queries.ts
- Add ProjectOfferWithMicro type to admin-queries.ts
- Extend ProjectFullDetail type with projectOffers + availableMicros fields
- Extend getProjectFullDetail() to query project offers and available micros in parallel
- Add z import and project_offers import to project-actions.ts
- Add assignOfferToProject, removeProjectOffer, updateProjectOfferTotal server actions
- All three actions call requireAdmin() and revalidate /admin/forecast
This commit is contained in:
2026-05-30 16:25:58 +02:00
parent 2faf2fedf1
commit b3f781b9b5
2 changed files with 103 additions and 2 deletions
+53 -1
View File
@@ -4,8 +4,9 @@ import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { db } from "@/db"; 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 { eq } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
async function requireAdmin() { async function requireAdmin() {
@@ -107,4 +108,55 @@ export async function splitPayment(
}); });
revalidatePath(`/admin/projects/${projectId}`); 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");
} }
+50 -1
View File
@@ -13,6 +13,8 @@ import {
quote_items, quote_items,
service_catalog, service_catalog,
settings, settings,
offer_micros,
project_offers,
} from "@/db/schema"; } from "@/db/schema";
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
import type { import type {
@@ -26,6 +28,8 @@ import type {
Note, Note,
Comment, Comment,
ServiceCatalog, ServiceCatalog,
OfferMicro,
ProjectOffer,
} from "@/db/schema"; } from "@/db/schema";
// ── ClientWithPayments — used by /admin clients list ───────────────────────── // ── ClientWithPayments — used by /admin clients list ─────────────────────────
@@ -182,6 +186,20 @@ export async function getClientById(id: string) {
return rows[0] ?? null; 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 ───────────────── // ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
// quote_items NEVER exposed via client API — admin workspace query only // quote_items NEVER exposed via client API — admin workspace query only
@@ -442,6 +460,8 @@ export type ProjectFullDetail = {
activeTimerEntryId: string | null; activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null; activeTimerStartedAt: Date | null;
totalTrackedSeconds: number; totalTrackedSeconds: number;
projectOffers: ProjectOfferWithMicro[];
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>;
}; };
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> { export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
@@ -488,7 +508,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.from(deliverables) .from(deliverables)
.where(inArray(deliverables.task_id, taskIds)); .where(inArray(deliverables.task_id, taskIds));
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes] = const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows] =
await Promise.all([ await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)), db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)), db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
@@ -517,6 +537,33 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` }) .select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries) .from(time_entries)
.where(eq(time_entries.project_id, id)), .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)]; const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
@@ -551,6 +598,8 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
activeTimerEntryId: activeEntryRows[0]?.id ?? null, activeTimerEntryId: activeEntryRows[0]?.id ?? null,
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null, activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0, totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
availableMicros: availableMicrosRows,
}; };
} }