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
+50 -1
View File
@@ -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<ProjectFullDetail | null> {
@@ -488,7 +508,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.from(deliverables)
.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([
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)),
@@ -517,6 +537,33 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.select({ total: sql<string>`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<ProjectFullDetai
activeTimerEntryId: activeEntryRows[0]?.id ?? null,
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
availableMicros: availableMicrosRows,
};
}