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
+12 -6
View File
@@ -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-21Roadmap v2.3 created (Phases 2325, 9 requirements mapped) Last activity: 2026-06-22Lead→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)
-13
View File
@@ -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}
+99 -3
View File
@@ -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}`);
} }
+1 -1
View File
@@ -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 },
+37 -9
View File
@@ -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} &middot; {offer.micro_duration_months}{" "} Pubblico: {offer.micro_public_name} &middot; {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&apos;offerta
<span className="block text-[#71717a]">
Crea le fasi raggruppando i servizi per &laquo;Fase&raquo;; 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&apos;offerta in{" "}
<a href="/admin/offers" className="underline">Offerte</a>. <a href="/admin/offers" className="underline">Offerte</a>.
</p> </p>
)} )}
-333
View File
@@ -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>
);
}
+20 -4
View File
@@ -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)];