feat: offer_type (una tantum/retainer), dedup tiers, redesigned Offerte tab

- offer_macros.offer_type ('una_tantum'|'retainer') + editor "Modalità" toggle
  (migration 0012, applied to prod)
- migration 0012 also adds UNIQUE(macro_id, tier_letter) — prevents duplicate
  tiers; ran after a one-time dedup of Web Domination's duplicate A/B/C tiers
- OffersTab redesigned: two-step assign (Offer → Tier cards with price), type
  badge "Una tantum/Ricorrente" instead of misleading "X mesi", no redundant
  "· A" when public_name == tier letter, cleaner active-offers cards
- getProjectFullDetail: availableMicros grouped by macro + defensive dedup;
  projectOffers/availableMicros carry offer_type/category/price
- proposal deck PricingSection shows offer type (fallback to duration for
  pre-existing proposals)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:32:59 +02:00
parent 1824cb643f
commit f98828f75e
9 changed files with 333 additions and 132 deletions
+29 -3
View File
@@ -212,6 +212,7 @@ export type ProjectOfferWithMicro = {
micro_duration_months: number;
tier_letter: string | null;
macro_internal_name: string;
macro_offer_type: string;
start_date: Date;
accepted_total: string | null;
created_at: Date;
@@ -556,7 +557,11 @@ export type ProjectFullDetail = {
public_name: string;
duration_months: number;
tier_letter: string | null;
public_price: string | null;
macro_id: string;
macro_internal_name: string;
macro_category: string | null;
macro_offer_type: string;
}>;
};
@@ -644,6 +649,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
micro_duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
macro_internal_name: offer_macros.internal_name,
macro_offer_type: offer_macros.offer_type,
start_date: project_offers.start_date,
accepted_total: project_offers.accepted_total,
created_at: project_offers.created_at,
@@ -653,7 +659,9 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, id))
.orderBy(asc(project_offers.created_at)),
// Query B: all tiers of non-archived offers (for the assignment dropdown)
// Query B: all tiers of non-archived offers (for the assignment dropdown).
// ordered oldest-first per (macro, tier) so client-side dedup keeps the
// oldest until the DB cleanup removes genuine duplicates.
db
.select({
id: offer_micros.id,
@@ -661,12 +669,20 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
public_name: offer_micros.public_name,
duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
public_price: offer_micros.public_price,
macro_id: offer_micros.macro_id,
macro_internal_name: offer_macros.internal_name,
macro_category: offer_macros.category,
macro_offer_type: offer_macros.offer_type,
})
.from(offer_micros)
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(offer_macros.is_archived, false))
.orderBy(asc(offer_macros.internal_name), asc(offer_micros.tier_letter)),
.orderBy(
asc(offer_macros.internal_name),
asc(offer_micros.tier_letter),
asc(offer_micros.id)
),
]);
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
@@ -689,6 +705,16 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
})),
}));
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
const seenTier = new Set<string>();
const dedupedMicros = availableMicrosRows.filter((m) => {
const key = `${m.macro_id}::${m.tier_letter ?? `_null_${m.id}`}`;
if (seenTier.has(key)) return false;
seenTier.add(key);
return true;
});
return {
project: { ...project, client } as ProjectFullDetail["project"],
phases: phasesWithTasks,
@@ -702,7 +728,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
availableMicros: availableMicrosRows,
availableMicros: dedupedMicros,
};
}