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
+1 -1
View File
@@ -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 },
+37 -9
View File
@@ -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"
>
<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]">
Pubblico: {offer.micro_public_name} &middot; {offer.micro_duration_months}{" "}
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
Pubblico: {offer.micro_public_name} &middot; {durationLabel(offer.micro_duration_months)}
</p>
<p className="text-xs text-[#71717a] mt-1">
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
@@ -107,7 +119,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
{/* Assignment form */}
<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
ref={formRef}
action={handleAssign}
@@ -116,17 +128,18 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
<input type="hidden" name="project_id" value={projectId} />
<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
name="micro_id"
required
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) => (
<option key={m.id} value={m.id}>
{m.internal_name} ({m.duration_months}{" "}
{m.duration_months === 1 ? "mese" : "mesi"})
{m.macro_internal_name}
{m.tier_letter ? ` — Tier ${m.tier_letter}` : ""} · {m.public_name} (
{durationLabel(m.duration_months)})
</option>
))}
</select>
@@ -144,6 +157,21 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
/>
</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
type="submit"
disabled={isPending || availableMicros.length === 0}
@@ -154,7 +182,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
{availableMicros.length === 0 && (
<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>.
</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>
);
}