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:
+12
-6
@@ -4,8 +4,8 @@ milestone: v2.3
|
|||||||
milestone_name: Email & Accesso
|
milestone_name: Email & Accesso
|
||||||
status: planning
|
status: planning
|
||||||
stopped_at: ""
|
stopped_at: ""
|
||||||
last_updated: "2026-06-21T11:05:00.000Z"
|
last_updated: "2026-06-22T09:00:00.000Z"
|
||||||
last_activity: 2026-06-21 -- Roadmap v2.3 created (Phases 23-25)
|
last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 3
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
@@ -26,10 +26,16 @@ See: .planning/PROJECT.md (updated 2026-06-21)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: Not started
|
Phase: Pre-23 fixes & flow wiring (fuori roadmap formale)
|
||||||
Plan: —
|
Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md`
|
||||||
Status: Roadmap created, ready to plan Phase 23
|
Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task
|
||||||
Last activity: 2026-06-21 — Roadmap v2.3 created (Phases 23–25, 9 requirements mapped)
|
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)
|
### Fasi completate (v2.2, storico)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
|||||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||||
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
|
||||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
@@ -34,8 +33,6 @@ export default async function ProjectDetailPage({
|
|||||||
documents,
|
documents,
|
||||||
notes,
|
notes,
|
||||||
comments,
|
comments,
|
||||||
quoteItems,
|
|
||||||
activeServices,
|
|
||||||
activeTimerEntryId,
|
activeTimerEntryId,
|
||||||
activeTimerStartedAt,
|
activeTimerStartedAt,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
@@ -72,7 +69,6 @@ export default async function ProjectDetailPage({
|
|||||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||||
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
|
||||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -118,15 +114,6 @@ export default async function ProjectDetailPage({
|
|||||||
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="quote">
|
|
||||||
<QuoteTab
|
|
||||||
clientId={id}
|
|
||||||
items={quoteItems}
|
|
||||||
activeServices={activeServices}
|
|
||||||
acceptedTotal={project.accepted_total ?? "0"}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="timer">
|
<TabsContent value="timer">
|
||||||
<TimerTab
|
<TimerTab
|
||||||
projectId={id}
|
projectId={id}
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { projects, clients, payments, project_offers } from "@/db/schema";
|
import {
|
||||||
import { eq } from "drizzle-orm";
|
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 { z } from "zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -110,12 +119,94 @@ export async function splitPayment(
|
|||||||
revalidatePath(`/admin/projects/${projectId}`);
|
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 ──────────────────────────────────────────────────
|
// ── Offer assignment actions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const assignOfferSchema = z.object({
|
const assignOfferSchema = z.object({
|
||||||
project_id: z.string().min(1),
|
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(),
|
accepted_total: z.coerce.number().min(0).optional(),
|
||||||
|
import_phases: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function assignOfferToProject(formData: FormData) {
|
export async function assignOfferToProject(formData: FormData) {
|
||||||
@@ -124,6 +215,7 @@ export async function assignOfferToProject(formData: FormData) {
|
|||||||
project_id: formData.get("project_id"),
|
project_id: formData.get("project_id"),
|
||||||
micro_id: formData.get("micro_id"),
|
micro_id: formData.get("micro_id"),
|
||||||
accepted_total: formData.get("accepted_total") || undefined,
|
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);
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
await db.insert(project_offers).values({
|
await db.insert(project_offers).values({
|
||||||
@@ -134,6 +226,10 @@ export async function assignOfferToProject(formData: FormData) {
|
|||||||
? parsed.data.accepted_total.toFixed(2)
|
? parsed.data.accepted_total.toFixed(2)
|
||||||
: null,
|
: 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}`);
|
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
|
||||||
{ href: "/admin/leads", label: "Lead", icon: Zap },
|
{ href: "/admin/leads", label: "Lead", icon: Zap },
|
||||||
|
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||||
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||||
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
|
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
|
||||||
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type AvailableMicro = {
|
|||||||
internal_name: string;
|
internal_name: string;
|
||||||
public_name: string;
|
public_name: string;
|
||||||
duration_months: number;
|
duration_months: number;
|
||||||
|
tier_letter: string | null;
|
||||||
|
macro_internal_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface OffersTabProps {
|
interface OffersTabProps {
|
||||||
@@ -22,6 +24,10 @@ interface OffersTabProps {
|
|||||||
availableMicros: AvailableMicro[];
|
availableMicros: AvailableMicro[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function durationLabel(months: number): string {
|
||||||
|
return `${months} ${months === 1 ? "mese" : "mesi"}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const router = useRouter();
|
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"
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
|
<p className="text-sm font-medium text-[#1a1a1a]">
|
||||||
|
{offer.macro_internal_name}
|
||||||
|
{offer.tier_letter && (
|
||||||
|
<span className="ml-2 inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5 align-middle">
|
||||||
|
Tier {offer.tier_letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-[#71717a]">
|
<p className="text-xs text-[#71717a]">
|
||||||
Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "}
|
Pubblico: {offer.micro_public_name} · {durationLabel(offer.micro_duration_months)}
|
||||||
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[#71717a] mt-1">
|
<p className="text-xs text-[#71717a] mt-1">
|
||||||
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
||||||
@@ -107,7 +119,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
|
|
||||||
{/* Assignment form */}
|
{/* Assignment form */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
|
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Offerta</h3>
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
action={handleAssign}
|
action={handleAssign}
|
||||||
@@ -116,17 +128,18 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
<input type="hidden" name="project_id" value={projectId} />
|
<input type="hidden" name="project_id" value={projectId} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
|
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
|
||||||
<select
|
<select
|
||||||
name="micro_id"
|
name="micro_id"
|
||||||
required
|
required
|
||||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Seleziona micro-offerta...</option>
|
<option value="">Seleziona offerta...</option>
|
||||||
{availableMicros.map((m) => (
|
{availableMicros.map((m) => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{m.internal_name} ({m.duration_months}{" "}
|
{m.macro_internal_name}
|
||||||
{m.duration_months === 1 ? "mese" : "mesi"})
|
{m.tier_letter ? ` — Tier ${m.tier_letter}` : ""} · {m.public_name} (
|
||||||
|
{durationLabel(m.duration_months)})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -144,6 +157,21 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="import_phases"
|
||||||
|
defaultChecked
|
||||||
|
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Importa fasi e task dall'offerta
|
||||||
|
<span className="block text-[#71717a]">
|
||||||
|
Crea le fasi raggruppando i servizi per «Fase»; ogni servizio diventa un task.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || availableMicros.length === 0}
|
disabled={isPending || availableMicros.length === 0}
|
||||||
@@ -154,7 +182,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
|
|
||||||
{availableMicros.length === 0 && (
|
{availableMicros.length === 0 && (
|
||||||
<p className="text-xs text-[#71717a]">
|
<p className="text-xs text-[#71717a]">
|
||||||
Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
|
Nessuna offerta disponibile. Crea prima un'offerta in{" "}
|
||||||
<a href="/admin/offers" className="underline">Offerte</a>.
|
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
addQuoteItem,
|
|
||||||
removeQuoteItem,
|
|
||||||
updateAcceptedTotal,
|
|
||||||
} from "@/app/admin/clients/[id]/quote-actions";
|
|
||||||
import type { QuoteItemWithLabel } from "@/lib/admin-queries";
|
|
||||||
import type { Service } from "@/db/schema";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
clientId: string;
|
|
||||||
items: QuoteItemWithLabel[];
|
|
||||||
activeServices: Service[];
|
|
||||||
acceptedTotal: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
|
|
||||||
const [showCustom, setShowCustom] = useState(false);
|
|
||||||
const [addError, setAddError] = useState<string | null>(null);
|
|
||||||
const [totalError, setTotalError] = useState<string | null>(null);
|
|
||||||
// For catalog mode: pre-fill unit_price when service is selected
|
|
||||||
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
|
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const calculatedTotal = items.reduce(
|
|
||||||
(sum, item) => sum + parseFloat(item.subtotal),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleAddItem(fd: FormData) {
|
|
||||||
setAddError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
await addQuoteItem(clientId, fd);
|
|
||||||
router.refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(quoteItemId: string) {
|
|
||||||
startTransition(async () => {
|
|
||||||
await removeQuoteItem(quoteItemId, clientId);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveTotal(fd: FormData) {
|
|
||||||
setTotalError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
await updateAcceptedTotal(clientId, fd);
|
|
||||||
router.refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setTotalError(
|
|
||||||
e instanceof Error ? e.message : "Errore nel salvataggio"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-2xl">
|
|
||||||
|
|
||||||
{/* Section 1: Add items */}
|
|
||||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
|
|
||||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
|
|
||||||
Aggiungi voci
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{!showCustom ? (
|
|
||||||
/* Catalog mode */
|
|
||||||
<form action={handleAddItem} className="space-y-3">
|
|
||||||
<input type="hidden" name="custom_label" value="" />
|
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
|
||||||
<div className="flex-1 min-w-[180px] space-y-1">
|
|
||||||
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
|
|
||||||
<select
|
|
||||||
name="service_id"
|
|
||||||
id="service_id"
|
|
||||||
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
|
||||||
onChange={(e) => {
|
|
||||||
const svc = activeServices.find((s) => s.id === e.target.value);
|
|
||||||
setSelectedServicePrice(
|
|
||||||
svc ? parseFloat(svc.unit_price).toFixed(2) : ""
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">— Scegli servizio —</option>
|
|
||||||
{activeServices.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.name} (€
|
|
||||||
{parseFloat(s.unit_price).toLocaleString("it-IT", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="w-28 space-y-1">
|
|
||||||
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
|
|
||||||
<Input
|
|
||||||
id="unit_price_catalog"
|
|
||||||
name="unit_price"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
value={selectedServicePrice}
|
|
||||||
onChange={(e) => setSelectedServicePrice(e.target.value)}
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-20 space-y-1">
|
|
||||||
<Label htmlFor="quantity_catalog">Qty</Label>
|
|
||||||
<Input
|
|
||||||
id="quantity_catalog"
|
|
||||||
name="quantity"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
defaultValue="1"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
|
||||||
>
|
|
||||||
Aggiungi
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCustom(true);
|
|
||||||
setAddError(null);
|
|
||||||
}}
|
|
||||||
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
|
|
||||||
>
|
|
||||||
Oppure aggiungi voce libera →
|
|
||||||
</button>
|
|
||||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
/* Freeform mode */
|
|
||||||
<form action={handleAddItem} className="space-y-3">
|
|
||||||
<input type="hidden" name="service_id" value="" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="custom_label">Nome voce</Label>
|
|
||||||
<Input
|
|
||||||
id="custom_label"
|
|
||||||
name="custom_label"
|
|
||||||
placeholder="es. Consulenza extra, Spese viaggi"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 flex-wrap">
|
|
||||||
<div className="flex-1 min-w-[120px] space-y-1">
|
|
||||||
<Label htmlFor="unit_price_custom">Prezzo unitario (€)</Label>
|
|
||||||
<Input
|
|
||||||
id="unit_price_custom"
|
|
||||||
name="unit_price"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-20 space-y-1">
|
|
||||||
<Label htmlFor="quantity_custom">Qty</Label>
|
|
||||||
<Input
|
|
||||||
id="quantity_custom"
|
|
||||||
name="quantity"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0.01"
|
|
||||||
defaultValue="1"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
|
||||||
>
|
|
||||||
Aggiungi voce libera
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCustom(false);
|
|
||||||
setAddError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Torna al catalogo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 2: Quote items table + calculated total */}
|
|
||||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
|
|
||||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
|
||||||
Voci preventivo
|
|
||||||
</h3>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<p className="text-sm text-[#71717a] py-4 text-center">
|
|
||||||
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b border-[#e5e7eb]">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left py-2 px-2 font-medium text-[#71717a]">
|
|
||||||
Voce
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
|
||||||
Qty
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
|
||||||
Prezzo unit.
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
|
||||||
Subtotale
|
|
||||||
</th>
|
|
||||||
<th className="py-2 px-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item) => (
|
|
||||||
<tr
|
|
||||||
key={item.id}
|
|
||||||
className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]"
|
|
||||||
>
|
|
||||||
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
|
|
||||||
<td className="py-2 px-2 text-right tabular-nums">
|
|
||||||
{parseFloat(item.quantity).toLocaleString("it-IT", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-2 text-right tabular-nums font-mono">
|
|
||||||
€
|
|
||||||
{parseFloat(item.unit_price).toLocaleString("it-IT", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
|
|
||||||
€
|
|
||||||
{parseFloat(item.subtotal).toLocaleString("it-IT", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-2 text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemove(item.id)}
|
|
||||||
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
|
|
||||||
>
|
|
||||||
Rimuovi
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
|
|
||||||
<p className="font-bold text-[#1a1a1a] tabular-nums">
|
|
||||||
Totale calcolato: €
|
|
||||||
{calculatedTotal.toLocaleString("it-IT", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 3: Accepted total */}
|
|
||||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
|
|
||||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
|
|
||||||
Totale accettato dal cliente
|
|
||||||
</h3>
|
|
||||||
<form action={handleSaveTotal} className="flex items-end gap-3">
|
|
||||||
<div className="flex-1 max-w-[200px] space-y-1">
|
|
||||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
|
||||||
<Input
|
|
||||||
id="accepted_total"
|
|
||||||
name="accepted_total"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
|
||||||
>
|
|
||||||
Salva
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
|
|
||||||
<p className="text-xs text-[#71717a]">
|
|
||||||
Il cliente vede solo questo importo, non le singole voci del preventivo.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -210,6 +210,8 @@ export type ProjectOfferWithMicro = {
|
|||||||
micro_internal_name: string;
|
micro_internal_name: string;
|
||||||
micro_public_name: string;
|
micro_public_name: string;
|
||||||
micro_duration_months: number;
|
micro_duration_months: number;
|
||||||
|
tier_letter: string | null;
|
||||||
|
macro_internal_name: string;
|
||||||
start_date: Date;
|
start_date: Date;
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@@ -548,7 +550,14 @@ export type ProjectFullDetail = {
|
|||||||
activeTimerStartedAt: Date | null;
|
activeTimerStartedAt: Date | null;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
projectOffers: ProjectOfferWithMicro[];
|
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> {
|
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)` })
|
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(eq(time_entries.project_id, id)),
|
.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
|
db
|
||||||
.select({
|
.select({
|
||||||
id: project_offers.id,
|
id: project_offers.id,
|
||||||
@@ -633,24 +642,31 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
micro_internal_name: offer_micros.internal_name,
|
micro_internal_name: offer_micros.internal_name,
|
||||||
micro_public_name: offer_micros.public_name,
|
micro_public_name: offer_micros.public_name,
|
||||||
micro_duration_months: offer_micros.duration_months,
|
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,
|
start_date: project_offers.start_date,
|
||||||
accepted_total: project_offers.accepted_total,
|
accepted_total: project_offers.accepted_total,
|
||||||
created_at: project_offers.created_at,
|
created_at: project_offers.created_at,
|
||||||
})
|
})
|
||||||
.from(project_offers)
|
.from(project_offers)
|
||||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
.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))
|
.where(eq(project_offers.project_id, id))
|
||||||
.orderBy(asc(project_offers.created_at)),
|
.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
|
db
|
||||||
.select({
|
.select({
|
||||||
id: offer_micros.id,
|
id: offer_micros.id,
|
||||||
internal_name: offer_micros.internal_name,
|
internal_name: offer_micros.internal_name,
|
||||||
public_name: offer_micros.public_name,
|
public_name: offer_micros.public_name,
|
||||||
duration_months: offer_micros.duration_months,
|
duration_months: offer_micros.duration_months,
|
||||||
|
tier_letter: offer_micros.tier_letter,
|
||||||
|
macro_internal_name: offer_macros.internal_name,
|
||||||
})
|
})
|
||||||
.from(offer_micros)
|
.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)];
|
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
|
|||||||
Reference in New Issue
Block a user