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
|
||||
status: planning
|
||||
stopped_at: ""
|
||||
last_updated: "2026-06-21T11:05:00.000Z"
|
||||
last_activity: 2026-06-21 -- Roadmap v2.3 created (Phases 23-25)
|
||||
last_updated: "2026-06-22T09:00:00.000Z"
|
||||
last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso
|
||||
progress:
|
||||
total_phases: 3
|
||||
completed_phases: 0
|
||||
@@ -26,10 +26,16 @@ See: .planning/PROJECT.md (updated 2026-06-21)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: Not started
|
||||
Plan: —
|
||||
Status: Roadmap created, ready to plan Phase 23
|
||||
Last activity: 2026-06-21 — Roadmap v2.3 created (Phases 23–25, 9 requirements mapped)
|
||||
Phase: Pre-23 fixes & flow wiring (fuori roadmap formale)
|
||||
Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md`
|
||||
Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task
|
||||
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)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||
@@ -34,8 +33,6 @@ export default async function ProjectDetailPage({
|
||||
documents,
|
||||
notes,
|
||||
comments,
|
||||
quoteItems,
|
||||
activeServices,
|
||||
activeTimerEntryId,
|
||||
activeTimerStartedAt,
|
||||
totalTrackedSeconds,
|
||||
@@ -72,7 +69,6 @@ export default async function ProjectDetailPage({
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -118,15 +114,6 @@ export default async function ProjectDetailPage({
|
||||
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quote">
|
||||
<QuoteTab
|
||||
clientId={id}
|
||||
items={quoteItems}
|
||||
activeServices={activeServices}
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timer">
|
||||
<TimerTab
|
||||
projectId={id}
|
||||
|
||||
@@ -4,8 +4,17 @@ import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { projects, clients, payments, project_offers } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
projects,
|
||||
clients,
|
||||
payments,
|
||||
project_offers,
|
||||
offer_tier_services,
|
||||
services,
|
||||
phases,
|
||||
tasks,
|
||||
} from "@/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -110,12 +119,94 @@ export async function splitPayment(
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
// ── Offer → project phases/tasks import ───────────────────────────────────────
|
||||
// An offer/tier has no phases of its own; it carries a flat list of services,
|
||||
// and each service has a `fase` (catalog taxonomy). We materialize project
|
||||
// phases by grouping the tier's services by `services.fase` (one phase per
|
||||
// distinct fase, one task per service). Idempotent + mergeable: existing phases
|
||||
// are matched by title (case-insensitive) and reused, duplicate tasks skipped —
|
||||
// so assigning a 2nd offer with shared fasi adds tasks without duplicating.
|
||||
|
||||
const NO_FASE_LABEL = "Generale";
|
||||
|
||||
export async function importOfferIntoProject(projectId: string, microId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
|
||||
// Tier services with their catalog fase, ordered (null fase last, then name).
|
||||
const rows = await db
|
||||
.select({ name: services.name, fase: services.fase })
|
||||
.from(offer_tier_services)
|
||||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||||
.where(eq(offer_tier_services.tier_id, microId))
|
||||
.orderBy(asc(services.fase), asc(services.name));
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Group service names by fase, preserving first-seen order.
|
||||
const groups: Array<{ fase: string; serviceNames: string[] }> = [];
|
||||
const groupIndex = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const fase = r.fase?.trim() || NO_FASE_LABEL;
|
||||
let idx = groupIndex.get(fase.toLowerCase());
|
||||
if (idx === undefined) {
|
||||
idx = groups.length;
|
||||
groupIndex.set(fase.toLowerCase(), idx);
|
||||
groups.push({ fase, serviceNames: [] });
|
||||
}
|
||||
groups[idx].serviceNames.push(r.name);
|
||||
}
|
||||
|
||||
// Existing project phases (for merge-by-title) + current max sort_order.
|
||||
const existingPhases = await db
|
||||
.select({ id: phases.id, title: phases.title, sort_order: phases.sort_order })
|
||||
.from(phases)
|
||||
.where(eq(phases.project_id, projectId));
|
||||
const phaseByTitle = new Map(existingPhases.map((p) => [p.title.trim().toLowerCase(), p]));
|
||||
let maxPhaseOrder = existingPhases.reduce((m, p) => Math.max(m, p.sort_order), -1);
|
||||
|
||||
for (const group of groups) {
|
||||
let phaseId: string;
|
||||
const existing = phaseByTitle.get(group.fase.toLowerCase());
|
||||
if (existing) {
|
||||
phaseId = existing.id;
|
||||
} else {
|
||||
maxPhaseOrder += 1;
|
||||
const [inserted] = await db
|
||||
.insert(phases)
|
||||
.values({ project_id: projectId, title: group.fase, sort_order: maxPhaseOrder, status: "upcoming" })
|
||||
.returning({ id: phases.id });
|
||||
phaseId = inserted.id;
|
||||
phaseByTitle.set(group.fase.toLowerCase(), { id: phaseId, title: group.fase, sort_order: maxPhaseOrder });
|
||||
}
|
||||
|
||||
// Skip tasks whose title already exists in this phase.
|
||||
const existingTasks = await db
|
||||
.select({ title: tasks.title, sort_order: tasks.sort_order })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.phase_id, phaseId));
|
||||
const taskTitles = new Set(existingTasks.map((t) => t.title.trim().toLowerCase()));
|
||||
let maxTaskOrder = existingTasks.reduce((m, t) => Math.max(m, t.sort_order), -1);
|
||||
|
||||
const toInsert = group.serviceNames
|
||||
.filter((name) => !taskTitles.has(name.trim().toLowerCase()))
|
||||
.map((name) => {
|
||||
taskTitles.add(name.trim().toLowerCase());
|
||||
maxTaskOrder += 1;
|
||||
return { phase_id: phaseId, title: name, status: "todo", sort_order: maxTaskOrder };
|
||||
});
|
||||
if (toInsert.length > 0) await db.insert(tasks).values(toInsert);
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
// ── Offer assignment actions ──────────────────────────────────────────────────
|
||||
|
||||
const assignOfferSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
micro_id: z.string().min(1, "Seleziona una micro-offerta"),
|
||||
micro_id: z.string().min(1, "Seleziona un'offerta"),
|
||||
accepted_total: z.coerce.number().min(0).optional(),
|
||||
import_phases: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function assignOfferToProject(formData: FormData) {
|
||||
@@ -124,6 +215,7 @@ export async function assignOfferToProject(formData: FormData) {
|
||||
project_id: formData.get("project_id"),
|
||||
micro_id: formData.get("micro_id"),
|
||||
accepted_total: formData.get("accepted_total") || undefined,
|
||||
import_phases: formData.get("import_phases") === "on",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.insert(project_offers).values({
|
||||
@@ -134,6 +226,10 @@ export async function assignOfferToProject(formData: FormData) {
|
||||
? parsed.data.accepted_total.toFixed(2)
|
||||
: null,
|
||||
});
|
||||
// Materialize project phases/tasks from the tier's services grouped by fase.
|
||||
if (parsed.data.import_phases) {
|
||||
await importOfferIntoProject(parsed.data.project_id, parsed.data.micro_id);
|
||||
}
|
||||
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -210,6 +210,8 @@ export type ProjectOfferWithMicro = {
|
||||
micro_internal_name: string;
|
||||
micro_public_name: string;
|
||||
micro_duration_months: number;
|
||||
tier_letter: string | null;
|
||||
macro_internal_name: string;
|
||||
start_date: Date;
|
||||
accepted_total: string | null;
|
||||
created_at: Date;
|
||||
@@ -548,7 +550,14 @@ export type ProjectFullDetail = {
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
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> {
|
||||
@@ -624,7 +633,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
|
||||
.from(time_entries)
|
||||
.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
|
||||
.select({
|
||||
id: project_offers.id,
|
||||
@@ -633,24 +642,31 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
micro_internal_name: offer_micros.internal_name,
|
||||
micro_public_name: offer_micros.public_name,
|
||||
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,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
created_at: project_offers.created_at,
|
||||
})
|
||||
.from(project_offers)
|
||||
.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))
|
||||
.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
|
||||
.select({
|
||||
id: offer_micros.id,
|
||||
internal_name: offer_micros.internal_name,
|
||||
public_name: offer_micros.public_name,
|
||||
duration_months: offer_micros.duration_months,
|
||||
tier_letter: offer_micros.tier_letter,
|
||||
macro_internal_name: offer_macros.internal_name,
|
||||
})
|
||||
.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)];
|
||||
|
||||
Reference in New Issue
Block a user