From 1824cb643f970b75f5626830cf41351b2631005d Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Mon, 22 Jun 2026 09:08:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20wire=20offer=E2=86=92project=20phases/t?= =?UTF-8?q?asks,=20redo=20Offerte=20tab,=20cleanup=20project=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .planning/STATE.md | 18 +- src/app/admin/projects/[id]/page.tsx | 13 - src/app/admin/projects/project-actions.ts | 102 ++++++- src/components/admin/AdminSidebar.tsx | 2 +- src/components/admin/tabs/OffersTab.tsx | 46 ++- src/components/admin/tabs/QuoteTab.tsx | 333 ---------------------- src/lib/admin-queries.ts | 24 +- 7 files changed, 169 insertions(+), 369 deletions(-) delete mode 100644 src/components/admin/tabs/QuoteTab.tsx diff --git a/.planning/STATE.md b/.planning/STATE.md index e9e8c45..3e110cb 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,8 +4,8 @@ milestone: v2.3 milestone_name: Email & Accesso status: planning stopped_at: "" -last_updated: "2026-06-21T11:05:00.000Z" -last_activity: 2026-06-21 -- Roadmap v2.3 created (Phases 23-25) +last_updated: "2026-06-22T09:00:00.000Z" +last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso progress: total_phases: 3 completed_phases: 0 @@ -26,10 +26,16 @@ See: .planning/PROJECT.md (updated 2026-06-21) ## Current Position -Phase: Not started -Plan: — -Status: Roadmap created, ready to plan Phase 23 -Last activity: 2026-06-21 — Roadmap v2.3 created (Phases 23–25, 9 requirements mapped) +Phase: Pre-23 fixes & flow wiring (fuori roadmap formale) +Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md` +Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task +Last activity: 2026-06-22 — Lead→Cliente consegnato in prod + +### Lavoro recente (pre-fase-23, in prod) + +- **Tassonomie**: gestione centralizzata categorie/tag in Impostazioni (modello Notion, pool persistenti, `src/lib/taxonomy.ts`). +- **Lead → Cliente (A+B)**: campi `clients.email/phone` + `leads.archived` (migrazione 0011 applicata a prod); `convertLeadToClient` (riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won"); tasto Converti/Convertito; lead archiviati nascosti da lista/kanban. +- **In corso**: rimozione tab Preventivo dal progetto, riordino sidebar, tab Offerte rifatta, import offerta→fasi/task per `services.fase`. ### Fasi completate (v2.2, storico) diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx index c2889d5..3d895b3 100644 --- a/src/app/admin/projects/[id]/page.tsx +++ b/src/app/admin/projects/[id]/page.tsx @@ -6,7 +6,6 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab"; import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; -import { QuoteTab } from "@/components/admin/tabs/QuoteTab"; import { TimerTab } from "@/components/admin/tabs/TimerTab"; import { OffersTab } from "@/components/admin/tabs/OffersTab"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; @@ -34,8 +33,6 @@ export default async function ProjectDetailPage({ documents, notes, comments, - quoteItems, - activeServices, activeTimerEntryId, activeTimerStartedAt, totalTrackedSeconds, @@ -72,7 +69,6 @@ export default async function ProjectDetailPage({ Documenti Note Commenti - Preventivo Timer Offerte @@ -118,15 +114,6 @@ export default async function ProjectDetailPage({ - - - - { + await requireAdmin(); + + // Tier services with their catalog fase, ordered (null fase last, then name). + const rows = await db + .select({ name: services.name, fase: services.fase }) + .from(offer_tier_services) + .innerJoin(services, eq(offer_tier_services.service_id, services.id)) + .where(eq(offer_tier_services.tier_id, microId)) + .orderBy(asc(services.fase), asc(services.name)); + + if (rows.length === 0) return; + + // Group service names by fase, preserving first-seen order. + const groups: Array<{ fase: string; serviceNames: string[] }> = []; + const groupIndex = new Map(); + for (const r of rows) { + const fase = r.fase?.trim() || NO_FASE_LABEL; + let idx = groupIndex.get(fase.toLowerCase()); + if (idx === undefined) { + idx = groups.length; + groupIndex.set(fase.toLowerCase(), idx); + groups.push({ fase, serviceNames: [] }); + } + groups[idx].serviceNames.push(r.name); + } + + // Existing project phases (for merge-by-title) + current max sort_order. + const existingPhases = await db + .select({ id: phases.id, title: phases.title, sort_order: phases.sort_order }) + .from(phases) + .where(eq(phases.project_id, projectId)); + const phaseByTitle = new Map(existingPhases.map((p) => [p.title.trim().toLowerCase(), p])); + let maxPhaseOrder = existingPhases.reduce((m, p) => Math.max(m, p.sort_order), -1); + + for (const group of groups) { + let phaseId: string; + const existing = phaseByTitle.get(group.fase.toLowerCase()); + if (existing) { + phaseId = existing.id; + } else { + maxPhaseOrder += 1; + const [inserted] = await db + .insert(phases) + .values({ project_id: projectId, title: group.fase, sort_order: maxPhaseOrder, status: "upcoming" }) + .returning({ id: phases.id }); + phaseId = inserted.id; + phaseByTitle.set(group.fase.toLowerCase(), { id: phaseId, title: group.fase, sort_order: maxPhaseOrder }); + } + + // Skip tasks whose title already exists in this phase. + const existingTasks = await db + .select({ title: tasks.title, sort_order: tasks.sort_order }) + .from(tasks) + .where(eq(tasks.phase_id, phaseId)); + const taskTitles = new Set(existingTasks.map((t) => t.title.trim().toLowerCase())); + let maxTaskOrder = existingTasks.reduce((m, t) => Math.max(m, t.sort_order), -1); + + const toInsert = group.serviceNames + .filter((name) => !taskTitles.has(name.trim().toLowerCase())) + .map((name) => { + taskTitles.add(name.trim().toLowerCase()); + maxTaskOrder += 1; + return { phase_id: phaseId, title: name, status: "todo", sort_order: maxTaskOrder }; + }); + if (toInsert.length > 0) await db.insert(tasks).values(toInsert); + } + + revalidatePath(`/admin/projects/${projectId}`); +} + // ── Offer assignment actions ────────────────────────────────────────────────── const assignOfferSchema = z.object({ project_id: z.string().min(1), - micro_id: z.string().min(1, "Seleziona una micro-offerta"), + micro_id: z.string().min(1, "Seleziona un'offerta"), accepted_total: z.coerce.number().min(0).optional(), + import_phases: z.boolean().optional(), }); export async function assignOfferToProject(formData: FormData) { @@ -124,6 +215,7 @@ export async function assignOfferToProject(formData: FormData) { project_id: formData.get("project_id"), micro_id: formData.get("micro_id"), accepted_total: formData.get("accepted_total") || undefined, + import_phases: formData.get("import_phases") === "on", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(project_offers).values({ @@ -134,6 +226,10 @@ export async function assignOfferToProject(formData: FormData) { ? parsed.data.accepted_total.toFixed(2) : null, }); + // Materialize project phases/tasks from the tier's services grouped by fase. + if (parsed.data.import_phases) { + await importOfferIntoProject(parsed.data.project_id, parsed.data.micro_id); + } revalidatePath(`/admin/projects/${parsed.data.project_id}`); } diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx index c8f8b64..49a990d 100644 --- a/src/components/admin/AdminSidebar.tsx +++ b/src/components/admin/AdminSidebar.tsx @@ -17,8 +17,8 @@ import { const NAV_ITEMS = [ { href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true }, - { href: "/admin/clients", label: "Clienti", icon: Users }, { href: "/admin/leads", label: "Lead", icon: Zap }, + { href: "/admin/clients", label: "Clienti", icon: Users }, { href: "/admin/projects", label: "Progetti", icon: FolderOpen }, { href: "/admin/preventivi", label: "Preventivi", icon: FileText }, { href: "/admin/offers", label: "Offerte", icon: Tag }, diff --git a/src/components/admin/tabs/OffersTab.tsx b/src/components/admin/tabs/OffersTab.tsx index 9099650..d532da7 100644 --- a/src/components/admin/tabs/OffersTab.tsx +++ b/src/components/admin/tabs/OffersTab.tsx @@ -14,6 +14,8 @@ type AvailableMicro = { internal_name: string; public_name: string; duration_months: number; + tier_letter: string | null; + macro_internal_name: string; }; interface OffersTabProps { @@ -22,6 +24,10 @@ interface OffersTabProps { availableMicros: AvailableMicro[]; } +function durationLabel(months: number): string { + return `${months} ${months === 1 ? "mese" : "mesi"}`; +} + export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) { const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -64,10 +70,16 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4" >
-

{offer.micro_internal_name}

+

+ {offer.macro_internal_name} + {offer.tier_letter && ( + + Tier {offer.tier_letter} + + )} +

- Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "} - {offer.micro_duration_months === 1 ? "mese" : "mesi"} + Pubblico: {offer.micro_public_name} · {durationLabel(offer.micro_duration_months)}

Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")} @@ -107,7 +119,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT {/* Assignment form */}

-

Assegna Micro-Offerta

+

Assegna Offerta

- + @@ -144,6 +157,21 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT />
+ + -
- - {addError &&

{addError}

} - - ) : ( - /* Freeform mode */ -
- -
- - -
-
-
- - -
-
- - -
-
- {addError &&

{addError}

} -
- - -
-
- )} -
- - {/* Section 2: Quote items table + calculated total */} -
-

- Voci preventivo -

- {items.length === 0 ? ( -

- Nessuna voce aggiunta. Seleziona dal catalogo per iniziare. -

- ) : ( - <> -
- - - - - - - - - - - - {items.map((item) => ( - - - - - - - - ))} - -
- Voce - - Qty - - Prezzo unit. - - Subtotale -
{item.label} - {parseFloat(item.quantity).toLocaleString("it-IT", { - minimumFractionDigits: 2, - })} - - € - {parseFloat(item.unit_price).toLocaleString("it-IT", { - minimumFractionDigits: 2, - })} - - € - {parseFloat(item.subtotal).toLocaleString("it-IT", { - minimumFractionDigits: 2, - })} - - -
-
-
-

- Totale calcolato: € - {calculatedTotal.toLocaleString("it-IT", { - minimumFractionDigits: 2, - })} -

-
- - )} -
- - {/* Section 3: Accepted total */} -
-

- Totale accettato dal cliente -

-
-
- - -
- -
- {totalError &&

{totalError}

} -

- Il cliente vede solo questo importo, non le singole voci del preventivo. -

-
- - - ); -} diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index e9b6703..1f1657e 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -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 { @@ -624,7 +633,7 @@ export async function getProjectFullDetail(id: string): Promise`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 d.id)];