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:
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user