feat: wire offer→project phases/tasks, redo Offerte tab, cleanup project tabs

- importOfferIntoProject(): materializes project phases/tasks from the assigned
  tier's services grouped by services.fase (merge-by-title, dedup tasks)
- assignOfferToProject: optional import_phases flag triggers the import
- getProjectFullDetail: projectOffers/availableMicros now include macro name +
  tier_letter (dropdown shows "Offerta — Tier X"); availableMicros filters
  non-archived macros
- OffersTab redone: shows macro + tier badge, "Importa fasi e task" checkbox
- removed project "Preventivo" tab + deleted QuoteTab.tsx (legacy quote_items)
- sidebar: Lead before Clienti
- no DB migration (reuses existing tables)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:08:28 +02:00
parent f5f90cd643
commit 1824cb643f
7 changed files with 169 additions and 369 deletions
+20 -4
View File
@@ -210,6 +210,8 @@ export type ProjectOfferWithMicro = {
micro_internal_name: string;
micro_public_name: string;
micro_duration_months: number;
tier_letter: string | null;
macro_internal_name: string;
start_date: Date;
accepted_total: string | null;
created_at: Date;
@@ -548,7 +550,14 @@ export type ProjectFullDetail = {
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
projectOffers: ProjectOfferWithMicro[];
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>;
availableMicros: Array<{
id: string;
internal_name: string;
public_name: string;
duration_months: number;
tier_letter: string | null;
macro_internal_name: string;
}>;
};
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
@@ -624,7 +633,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries)
.where(eq(time_entries.project_id, id)),
// Query A: project offers for this project joined with micro info
// Query A: project offers for this project joined with micro + macro info
db
.select({
id: project_offers.id,
@@ -633,24 +642,31 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
micro_internal_name: offer_micros.internal_name,
micro_public_name: offer_micros.public_name,
micro_duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
macro_internal_name: offer_macros.internal_name,
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))
.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 active micro-offers (for the assignment dropdown)
// Query B: all tiers of non-archived 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,
tier_letter: offer_micros.tier_letter,
macro_internal_name: offer_macros.internal_name,
})
.from(offer_micros)
.orderBy(asc(offer_micros.internal_name)),
.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)),
]);
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];