--- plan_id: 05-03 phase: 5 wave: 3 title: "Project offer assignment — OffersTab in project workspace + offer queries extension" type: execute depends_on: [05-01, 05-02] files_modified: - src/lib/admin-queries.ts - src/app/admin/projects/[id]/page.tsx - src/app/admin/projects/project-actions.ts - src/components/admin/tabs/OffersTab.tsx - src/app/admin/clients/[id]/page.tsx requirements_addressed: [OFFER-04] autonomous: true must_haves: truths: - "Admin can assign a micro-offer to a project from the project workspace Offerte tab" - "The Offerte tab shows all active offers assigned to a project with micro name, duration, and accepted_total" - "Admin can set the accepted_total on a project offer assignment" - "ProjectFullDetail type includes projectOffers array" - "Client detail page /admin/clients/[id] shows active offers summary (offer public_name + project name) for all of that client's projects" artifacts: - path: "src/components/admin/tabs/OffersTab.tsx" provides: "Client component for assigning and viewing project offers" contains: "assign form, offer list, accepted_total input" - path: "src/app/admin/projects/project-actions.ts" provides: "Server actions for project offer assignment mutations" contains: "assignOfferToProject, removeProjectOffer, updateProjectOfferTotal" - path: "src/app/admin/clients/[id]/page.tsx" provides: "Client detail page extended with active offers summary section" contains: "active offers per project listed with public_name and project name" key_links: - from: "src/app/admin/clients/[id]/page.tsx" to: "src/lib/admin-queries.ts" via: "getClientWithProjectsAndOffers() or extended getClientWithProjects()" pattern: "activeOffers" - from: "src/app/admin/projects/[id]/page.tsx" to: "src/lib/admin-queries.ts" via: "getProjectFullDetail() — extended to include projectOffers" pattern: "projectOffers" - from: "src/components/admin/tabs/OffersTab.tsx" to: "src/app/admin/projects/project-actions.ts" via: "assignOfferToProject server action" pattern: "assignOfferToProject" --- Add an "Offerte" tab to the project workspace at `/admin/projects/[id]` that lets the admin assign micro-offers to a project, set the offer-level accepted total, and view all active assignments. Purpose: This is the assignment layer — it connects the offer catalog (Plan 02) to specific projects. Without this, offers exist in the catalog but cannot be associated with client work. Output: `OffersTab.tsx` component; project-actions.ts extended with offer actions; `getProjectFullDetail` extended to return `projectOffers`; `/admin/projects/[id]` page updated with the new tab. @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md @/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-02-SUMMARY.md ```typescript export type ProjectFullDetail = { project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } }; phases: Array }>; payments: Payment[]; documents: Document[]; notes: Note[]; comments: Comment[]; quoteItems: QuoteItemWithLabel[]; activeServices: ServiceCatalog[]; activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; totalTrackedSeconds: number; // ADD: // projectOffers: ProjectOfferWithMicro[]; // availableMicros: OfferMicro[]; }; ``` ```tsx // Existing tabs: phases | payments | documents | notes | comments | quote | timer // ADD: Offerte // ADD: ``` ```typescript export const project_offers = pgTable("project_offers", { id: text("id").primaryKey(), project_id: text("project_id").notNull(), // FK → projects micro_id: text("micro_id").notNull(), // FK → offer_micros (RESTRICT on delete) start_date: timestamp(...).notNull().defaultNow(), accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), // nullable created_at: timestamp(...).notNull().defaultNow(), }); export type ProjectOffer = typeof project_offers.$inferSelect; export type OfferMicro = typeof offer_micros.$inferSelect; ``` ```typescript "use server"; async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } // revalidatePath(`/admin/projects/${projectId}`); // revalidatePath("/admin/forecast"); ``` Task 1: Extend getProjectFullDetail + add project offer server actions src/lib/admin-queries.ts src/app/admin/projects/project-actions.ts - src/lib/admin-queries.ts — read the FULL file; understand ProjectFullDetail type and getProjectFullDetail function - src/app/admin/projects/project-actions.ts — read the FULL file; understand existing action patterns - src/db/schema.ts — confirm project_offers, offer_micros column names after 05-01 **Extend `src/lib/admin-queries.ts`:** 1. Add imports for new offer tables at the top of the import block: ```typescript import { // existing imports... offer_micros, project_offers, } from "@/db/schema"; import type { // existing types... OfferMicro, ProjectOffer, } from "@/db/schema"; ``` 2. Add a new type after the existing types: ```typescript 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; }; ``` 3. Extend `ProjectFullDetail` type — add two fields at the end of the type definition: ```typescript projectOffers: ProjectOfferWithMicro[]; availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>; ``` 4. In the `getProjectFullDetail` function, extend the parallel `Promise.all` array to include two new queries alongside the existing ones. Add them to the destructuring and return. The existing `Promise.all` is at line ~491; add two new queries to the array: ```typescript // Add these two to the Promise.all: // 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)), ``` Destructure the two new results from `Promise.all` as `projectOffersRows` and `availableMicrosRows`. Add them to the return object: ```typescript projectOffers: projectOffersRows as ProjectOfferWithMicro[], availableMicros: availableMicrosRows, ``` **Extend `src/app/admin/projects/project-actions.ts`:** Add these three new server actions at the end of the file. Copy the `requireAdmin()` pattern exactly as it appears in the existing file: ```typescript // ── Offer assignment actions ───────────────────────────────────────────────── import { project_offers } from "@/db/schema"; // add to existing imports at top 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"); } ``` Note: `project-actions.ts` already imports `z`, `revalidatePath`, `db`, `eq`, and `requireAdmin()` — check the existing imports and add only what is missing (`project_offers` table import). cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 - `npx tsc --noEmit` exits 0 - `grep "projectOffers\|availableMicros" src/lib/admin-queries.ts` returns at least 4 matches (type definition + return) - `grep "assignOfferToProject\|removeProjectOffer\|updateProjectOfferTotal" src/app/admin/projects/project-actions.ts` returns 3 matches - `grep "revalidatePath.*forecast" src/app/admin/projects/project-actions.ts` returns at least 3 matches (one per offer action) admin-queries.ts extended with projectOffers field; project-actions.ts has three new offer actions; TypeScript compiles Task 2: OffersTab component + project workspace page update src/components/admin/tabs/OffersTab.tsx src/app/admin/projects/[id]/page.tsx - src/app/admin/projects/[id]/page.tsx — read FULL file before editing (understand current Tabs structure, destructuring, imports) - src/components/admin/tabs/QuoteTab.tsx — read first 40 lines for component prop pattern (not logic) - src/lib/admin-queries.ts — confirm ProjectOfferWithMicro and availableMicros types (just extended in Task 1) **Create `src/components/admin/tabs/OffersTab.tsx`:** This is a `"use client"` component that handles assignment form submission and inline accepted_total editing. ```typescript "use client"; import { useTransition, useRef } from "react"; import { useRouter } from "next/navigation"; import { assignOfferToProject, removeProjectOffer, updateProjectOfferTotal, } from "@/app/admin/projects/project-actions"; import type { ProjectOfferWithMicro } from "@/lib/admin-queries"; type AvailableMicro = { id: string; internal_name: string; public_name: string; duration_months: number; }; interface OffersTabProps { projectId: string; projectOffers: ProjectOfferWithMicro[]; availableMicros: AvailableMicro[]; } export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) { const [isPending, startTransition] = useTransition(); const router = useRouter(); const formRef = useRef(null); function handleAssign(formData: FormData) { startTransition(async () => { await assignOfferToProject(formData); formRef.current?.reset(); router.refresh(); }); } function handleRemove(offerId: string) { startTransition(async () => { await removeProjectOffer(offerId, projectId); router.refresh(); }); } function handleTotalUpdate(offerId: string, value: string) { startTransition(async () => { await updateProjectOfferTotal(offerId, projectId, value); router.refresh(); }); } return (
{/* Active assignments */}

Offerte Attive

{projectOffers.length === 0 ? (

Nessuna offerta assegnata a questo progetto.

) : (
{projectOffers.map((offer) => (

{offer.micro_internal_name}

Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "} {offer.micro_duration_months === 1 ? "mese" : "mesi"}

Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}

{/* Accepted total inline edit */}
{ const val = e.currentTarget.value.trim(); if (val !== (offer.accepted_total ?? "")) { handleTotalUpdate(offer.id, val); } }} className="w-24 border rounded px-2 py-0.5 text-xs" />
))}
)}
{/* Assignment form */}

Assegna Micro-Offerta

{availableMicros.length === 0 && (

Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "} Offerte.

)}
); } ``` **Update `src/app/admin/projects/[id]/page.tsx`:** Read the full file. Make these three targeted changes: 1. Add import for `OffersTab` at the top of the imports: ```typescript import { OffersTab } from "@/components/admin/tabs/OffersTab"; ``` 2. Destructure the two new fields from `detail`: ```typescript // Add to the existing destructuring: const { // ...existing fields... projectOffers, availableMicros, } = detail; ``` 3. Add the Offerte tab trigger and content inside the `` component, after the `Timer` line: ```tsx Offerte ``` And after the last ``: ```tsx ```
cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 - `npx tsc --noEmit` exits 0 - `grep "OffersTab" src/app/admin/projects/[id]/page.tsx` returns 2 matches (import + usage) - `grep "projectOffers\|availableMicros" src/app/admin/projects/[id]/page.tsx` returns at least 2 matches - File `src/components/admin/tabs/OffersTab.tsx` exists - `grep "assignOfferToProject\|removeProjectOffer\|updateProjectOfferTotal" src/components/admin/tabs/OffersTab.tsx` returns 3 matches OffersTab created; project workspace page has Offerte tab; admin can assign micro-offers and set accepted_total; TypeScript compiles
Task 3: Add getClientActiveOffers query + client detail page active offers summary src/lib/admin-queries.ts src/app/admin/clients/[id]/page.tsx - src/lib/admin-queries.ts — read the getClientWithProjects function and ClientWithProjects type (to extend alongside, not replace) - src/app/admin/clients/[id]/page.tsx — read the FULL file; understand the current project card layout - src/db/schema.ts — confirm project_offers.micro_id, offer_micros.public_name, project_offers.project_id column names (after 05-01) **Add new query to `src/lib/admin-queries.ts`:** Add `offer_micros` and `project_offers` to the existing schema imports at the top (they were added in Task 1 of this plan). Then add the following new exported type and function after `getClientWithProjects()`: ```typescript export type ClientActiveOfferSummary = { offer_id: string; project_id: string; project_name: string; public_name: string; // offer_micros.public_name — NEVER internal_name accepted_total: string | null; }; export async function getClientActiveOffers(clientId: string): Promise { const projectRows = await db .select({ id: projects.id, name: projects.name }) .from(projects) .where(eq(projects.client_id, clientId)); if (projectRows.length === 0) return []; const projectIds = projectRows.map((p) => p.id); const projectNameMap = new Map(projectRows.map((p) => [p.id, p.name])); // Explicit column selection — public_name only, NEVER internal_name const rows = await db .select({ offer_id: project_offers.id, project_id: project_offers.project_id, public_name: offer_micros.public_name, accepted_total: project_offers.accepted_total, }) .from(project_offers) .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) .where(inArray(project_offers.project_id, projectIds)) .orderBy(asc(project_offers.created_at)); return rows.map((r) => ({ offer_id: r.offer_id, project_id: r.project_id, project_name: projectNameMap.get(r.project_id) ?? "—", public_name: r.public_name, accepted_total: r.accepted_total ? String(r.accepted_total) : null, })); } ``` Note: `inArray`, `asc`, `eq` are already imported. `project_offers` and `offer_micros` schema imports were added in Task 1 of this plan. **Extend `src/app/admin/clients/[id]/page.tsx`:** Read the full file. It currently calls `getClientWithProjects(id)` and awaits a single result. Make these three targeted changes: 1. Add `getClientActiveOffers` to the existing import: ```typescript import { getClientWithProjects, getClientActiveOffers } from "@/lib/admin-queries"; ``` 2. Replace the single `await getClientWithProjects(id)` call with a parallel fetch: ```typescript const [data, activeOffers] = await Promise.all([ getClientWithProjects(id), getClientActiveOffers(id), ]); if (!data) notFound(); ``` 3. Add the "Offerte Attive" summary section at the bottom of the JSX return, after the `archivedProjects` block and before the closing `` of the root element: ```tsx {activeOffers.length > 0 && (

Offerte Attive ({activeOffers.length})

{activeOffers.map((offer) => (

{offer.public_name}

{offer.project_name}

{offer.accepted_total && ( €{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} )}
))}
)} ```
npx tsc --noEmit 2>&1 | head -20 - `npx tsc --noEmit` exits 0 - `grep "getClientActiveOffers" src/lib/admin-queries.ts` matches (new function exported) - `grep "ClientActiveOfferSummary" src/lib/admin-queries.ts` matches (type exported) - `grep "getClientActiveOffers" "src/app/admin/clients/[id]/page.tsx"` matches (function called on page) - `grep "activeOffers" "src/app/admin/clients/[id]/page.tsx"` returns at least 2 matches (fetch + render) - `grep "internal_name" src/lib/admin-queries.ts` — must NOT appear in the new getClientActiveOffers query block getClientActiveOffers() exported from admin-queries.ts; /admin/clients/[id] page shows active offers summary with public_name and project name; TypeScript compiles
## Trust Boundaries | Boundary | Description | |----------|-------------| | Browser → project-actions.ts | Admin assigns offers to projects via server actions | | Admin session → project_offers mutations | Unauthenticated access must be rejected | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-05-06 | Elevation of Privilege | assignOfferToProject, removeProjectOffer, updateProjectOfferTotal | mitigate | `requireAdmin()` as first call in each action | | T-05-07 | Tampering | removeProjectOffer — deletes project_offer row | mitigate | Action requires projectId param for revalidatePath only; deletion targets by projectOfferId (PK); no bulk delete possible | | T-05-08 | Information Disclosure | OffersTab renders micro internal_name | accept | Tab is under /admin/projects/* — Auth.js session guard; internal_name exposure to admin is intentional and correct | After all three tasks complete: - `npx tsc --noEmit` exits 0 - Visit `/admin/projects/[id]` → Offerte tab is visible - Offerte tab shows empty state when no offers assigned - Assign a micro-offer → appears in the active list - Set accepted_total by blurring the input → value persists on refresh - Remove an assignment → disappears from list - Visit `/admin/clients/[id]` for a client with active project offers → "Offerte Attive" section appears at the bottom with public_name and project name - Client with no active offers → no Offerte Attive section visible 1. Admin can assign a micro-offer to a project with optional accepted_total 2. Project workspace Offerte tab lists active assignments with micro name, duration, accepted_total 3. Admin can update the accepted_total inline (onBlur save) 4. Admin can remove a project offer assignment 5. All changes revalidate /admin/forecast path 6. Client detail page /admin/clients/[id] shows active offers summary (public_name + project name) for all projects belonging to that client After completion, create `.planning/phases/05-offer-system/05-03-SUMMARY.md` using the summary template.