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