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
-13
View File
@@ -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({
<TabsTrigger value="documents">Documenti</TabsTrigger>
<TabsTrigger value="notes">Note</TabsTrigger>
<TabsTrigger value="comments">Commenti</TabsTrigger>
<TabsTrigger value="quote">Preventivo</TabsTrigger>
<TabsTrigger value="timer">Timer</TabsTrigger>
<TabsTrigger value="offers">Offerte</TabsTrigger>
</TabsList>
@@ -118,15 +114,6 @@ export default async function ProjectDetailPage({
<CommentsTab comments={comments} phases={phases} clientId={id} />
</TabsContent>
<TabsContent value="quote">
<QuoteTab
clientId={id}
items={quoteItems}
activeServices={activeServices}
acceptedTotal={project.accepted_total ?? "0"}
/>
</TabsContent>
<TabsContent value="timer">
<TimerTab
projectId={id}
+99 -3
View File
@@ -4,8 +4,17 @@ import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/db";
import { projects, clients, payments, project_offers } from "@/db/schema";
import { eq } from "drizzle-orm";
import {
projects,
clients,
payments,
project_offers,
offer_tier_services,
services,
phases,
tasks,
} from "@/db/schema";
import { eq, asc } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
@@ -110,12 +119,94 @@ export async function splitPayment(
revalidatePath(`/admin/projects/${projectId}`);
}
// ── Offer → project phases/tasks import ───────────────────────────────────────
// An offer/tier has no phases of its own; it carries a flat list of services,
// and each service has a `fase` (catalog taxonomy). We materialize project
// phases by grouping the tier's services by `services.fase` (one phase per
// distinct fase, one task per service). Idempotent + mergeable: existing phases
// are matched by title (case-insensitive) and reused, duplicate tasks skipped —
// so assigning a 2nd offer with shared fasi adds tasks without duplicating.
const NO_FASE_LABEL = "Generale";
export async function importOfferIntoProject(projectId: string, microId: string): Promise<void> {
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<string, number>();
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}`);
}