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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user