feat(03-03): Server Actions quote CRUD + extend getClientFullDetail

- Create quote-actions.ts: addQuoteItem, removeQuoteItem, updateAcceptedTotal
- All three actions guarded by requireAdmin() + Zod validation
- Extend admin-queries.ts: QuoteItemWithLabel type, quoteItems + activeServices queries
- quote_items NEVER exposed via client-facing routes (security constraint enforced)
This commit is contained in:
Simone Cavalli
2026-05-17 11:42:44 +02:00
parent 29bfd88255
commit db81829b85
2 changed files with 119 additions and 0 deletions
+40
View File
@@ -9,6 +9,8 @@ import {
documents,
notes,
time_entries,
quote_items,
service_catalog,
} from "@/db/schema";
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
import type {
@@ -20,6 +22,7 @@ import type {
Document,
Note,
Comment,
ServiceCatalog,
} from "@/db/schema";
export type ClientWithPayments = {
@@ -108,6 +111,17 @@ export async function getClientById(id: string) {
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
// quote_items NEVER exposed via client API — admin workspace query only
export type QuoteItemWithLabel = {
id: string;
label: string; // COALESCE(service_catalog.name, quote_items.custom_label)
custom_label: string | null;
service_id: string | null;
quantity: string;
unit_price: string; // snapshotted — never joined back to service_catalog.unit_price
subtotal: string;
};
export type ClientFullDetail = {
client: Client;
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
@@ -115,6 +129,8 @@ export type ClientFullDetail = {
documents: Document[];
notes: Note[];
comments: Comment[];
quoteItems: QuoteItemWithLabel[];
activeServices: ServiceCatalog[];
};
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
@@ -180,6 +196,28 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
})),
}));
// quote_items NEVER exposed via client API — admin workspace query only
const quoteItemRows: QuoteItemWithLabel[] = await db
.select({
id: quote_items.id,
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
custom_label: quote_items.custom_label,
service_id: quote_items.service_id,
quantity: quote_items.quantity,
unit_price: quote_items.unit_price,
subtotal: quote_items.subtotal,
})
.from(quote_items)
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
.where(eq(quote_items.client_id, id))
.orderBy(asc(quote_items.id));
const activeServiceRows = await db
.select()
.from(service_catalog)
.where(eq(service_catalog.active, true))
.orderBy(asc(service_catalog.name));
return {
client,
phases: phasesWithTasks,
@@ -187,5 +225,7 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
quoteItems: quoteItemRows,
activeServices: activeServiceRows,
};
}