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