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:
@@ -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 },
|
||||
|
||||
@@ -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} · {offer.micro_duration_months}{" "}
|
||||
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
|
||||
Pubblico: {offer.micro_public_name} · {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'offerta
|
||||
<span className="block text-[#71717a]">
|
||||
Crea le fasi raggruppando i servizi per «Fase»; 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'offerta in{" "}
|
||||
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user