Files
clienthub/.planning/phases/05-offer-system/05-03-PLAN.md
T
simone cd55cba56c docs(05): create Phase 5 Offer System plans — 4 plans in 3 waves
Wave 1: 05-01 schema migration (5 new tables, additive only)
Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar)
Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:07:52 +02:00

20 KiB

plan_id, phase, wave, title, type, depends_on, files_modified, requirements_addressed, autonomous, must_haves
plan_id phase wave title type depends_on files_modified requirements_addressed autonomous must_haves
05-03 5 3 Project offer assignment — OffersTab in project workspace + offer queries extension execute
05-01
05-02
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
OFFER-04
true
truths artifacts key_links
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
path provides contains
src/components/admin/tabs/OffersTab.tsx Client component for assigning and viewing project offers assign form, offer list, accepted_total input
path provides contains
src/app/admin/projects/project-actions.ts Server actions for project offer assignment mutations assignOfferToProject, removeProjectOffer, updateProjectOfferTotal
from to via pattern
src/app/admin/projects/[id]/page.tsx src/lib/admin-queries.ts getProjectFullDetail() — extended to include projectOffers projectOffers
from to via pattern
src/components/admin/tabs/OffersTab.tsx src/app/admin/projects/project-actions.ts assignOfferToProject server action 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.

<execution_context> @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md </execution_context>

@/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[]; }; ```
// Existing tabs: phases | payments | documents | notes | comments | quote | timer
// ADD: <TabsTrigger value="offers">Offerte</TabsTrigger>
// ADD: <TabsContent value="offers"><OffersTab ... /></TabsContent>
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;
"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

<read_first> - 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 </read_first>

**Extend `src/lib/admin-queries.ts`:**
  1. Add imports for new offer tables at the top of the import block:
import {
  // existing imports...
  offer_micros, project_offers,
} from "@/db/schema";
import type {
  // existing types...
  OfferMicro, ProjectOffer,
} from "@/db/schema";
  1. Add a new type after the existing types:
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;
};
  1. Extend ProjectFullDetail type — add two fields at the end of the type definition:
projectOffers: ProjectOfferWithMicro[];
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>;
  1. 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:
// 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:

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:

// ── 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

<acceptance_criteria> - 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) </acceptance_criteria>

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

<read_first> - 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) </read_first>

**Create `src/components/admin/tabs/OffersTab.tsx`:**

This is a "use client" component that handles assignment form submission and inline accepted_total editing.

"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<HTMLFormElement>(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 (
    <div className="space-y-6 max-w-2xl">
      {/* Active assignments */}
      <div>
        <h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
        {projectOffers.length === 0 ? (
          <p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
        ) : (
          <div className="space-y-3">
            {projectOffers.map((offer) => (
              <div
                key={offer.id}
                className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
              >
                <div className="flex-1">
                  <p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
                  <p className="text-xs text-[#71717a]">
                    Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "}
                    {offer.micro_duration_months === 1 ? "mese" : "mesi"}
                  </p>
                  <p className="text-xs text-[#71717a] mt-1">
                    Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
                  </p>
                  {/* Accepted total inline edit */}
                  <div className="flex items-center gap-2 mt-2">
                    <label className="text-xs text-[#71717a]">Totale accettato :</label>
                    <input
                      type="number"
                      step="0.01"
                      min="0"
                      defaultValue={offer.accepted_total ?? ""}
                      placeholder="0.00"
                      onBlur={(e) => {
                        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"
                    />
                  </div>
                </div>
                <button
                  type="button"
                  onClick={() => handleRemove(offer.id)}
                  disabled={isPending}
                  className="text-xs text-red-600 hover:underline shrink-0"
                >
                  Rimuovi
                </button>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Assignment form */}
      <div>
        <h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
        <form
          ref={formRef}
          action={handleAssign}
          className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
        >
          <input type="hidden" name="project_id" value={projectId} />

          <div>
            <label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
            <select
              name="micro_id"
              required
              className="w-full border rounded px-3 py-1.5 text-sm"
            >
              <option value="">Seleziona micro-offerta...</option>
              {availableMicros.map((m) => (
                <option key={m.id} value={m.id}>
                  {m.internal_name} ({m.duration_months}{" "}
                  {m.duration_months === 1 ? "mese" : "mesi"})
                </option>
              ))}
            </select>
          </div>

          <div>
            <label className="text-xs text-[#71717a] block mb-1">Totale accettato  (opzionale)</label>
            <input
              name="accepted_total"
              type="number"
              step="0.01"
              min="0"
              placeholder="0.00"
              className="w-full border rounded px-3 py-1.5 text-sm"
            />
          </div>

          <button
            type="submit"
            disabled={isPending || availableMicros.length === 0}
            className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
          >
            {isPending ? "Salvataggio..." : "Assegna Offerta"}
          </button>

          {availableMicros.length === 0 && (
            <p className="text-xs text-[#71717a]">
              Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
              <a href="/admin/offers" className="underline">Offerte</a>.
            </p>
          )}
        </form>
      </div>
    </div>
  );
}

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:
import { OffersTab } from "@/components/admin/tabs/OffersTab";
  1. Destructure the two new fields from detail:
// Add to the existing destructuring:
const {
  // ...existing fields...
  projectOffers,
  availableMicros,
} = detail;
  1. Add the Offerte tab trigger and content inside the <Tabs> component, after the <TabsTrigger value="timer">Timer</TabsTrigger> line:
<TabsTrigger value="offers">Offerte</TabsTrigger>

And after the last <TabsContent>:

<TabsContent value="offers">
  <OffersTab
    projectId={id}
    projectOffers={projectOffers}
    availableMicros={availableMicros}
  />
</TabsContent>
cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20

<acceptance_criteria> - 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 </acceptance_criteria>

OffersTab created; project workspace page has Offerte tab; admin can assign micro-offers and set accepted_total; TypeScript compiles

<threat_model>

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
</threat_model>
After both 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

<success_criteria>

  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 </success_criteria>
After completion, create `.planning/phases/05-offer-system/05-03-SUMMARY.md` using the summary template.