Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 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 |
|
|
|
true |
|
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");
<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`:**- 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";
- 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;
};
- Extend
ProjectFullDetailtype — 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 }>;
- In the
getProjectFullDetailfunction, extend the parallelPromise.allarray to include two new queries alongside the existing ones. Add them to the destructuring and return. The existingPromise.allis 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).
<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:
- Add import for
OffersTabat the top of the imports:
import { OffersTab } from "@/components/admin/tabs/OffersTab";
- Destructure the two new fields from
detail:
// Add to the existing destructuring:
const {
// ...existing fields...
projectOffers,
availableMicros,
} = detail;
- 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>
<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
Task 3: Add getClientActiveOffers query + client detail page active offers summary src/lib/admin-queries.ts src/app/admin/clients/[id]/page.tsx<read_first> - 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) </read_first>
**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():
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<ClientActiveOfferSummary[]> {
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:
- Add
getClientActiveOffersto the existing import:
import { getClientWithProjects, getClientActiveOffers } from "@/lib/admin-queries";
- Replace the single
await getClientWithProjects(id)call with a parallel fetch:
const [data, activeOffers] = await Promise.all([
getClientWithProjects(id),
getClientActiveOffers(id),
]);
if (!data) notFound();
- Add the "Offerte Attive" summary section at the bottom of the JSX return, after the
archivedProjectsblock and before the closing</div>of the root element:
{activeOffers.length > 0 && (
<div className="mt-8">
<p className="text-xs text-[#71717a] font-semibold uppercase tracking-wider mb-3">
Offerte Attive ({activeOffers.length})
</p>
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
{activeOffers.map((offer) => (
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
<p className="text-xs text-[#71717a]">{offer.project_name}</p>
</div>
{offer.accepted_total && (
<span className="text-sm font-mono text-[#1a1a1a]">
€{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
))}
</div>
</div>
)}
<acceptance_criteria>
- 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
</acceptance_criteria>
getClientActiveOffers() exported from admin-queries.ts; /admin/clients/[id] page shows active offers summary with public_name and project name; 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> |
<success_criteria>
- Admin can assign a micro-offer to a project with optional accepted_total
- Project workspace Offerte tab lists active assignments with micro name, duration, accepted_total
- Admin can update the accepted_total inline (onBlur save)
- Admin can remove a project offer assignment
- All changes revalidate /admin/forecast path
- Client detail page /admin/clients/[id] shows active offers summary (public_name + project name) for all projects belonging to that client </success_criteria>