feat: sidebar CTA removal, delete phase/task, public dashboard UX, chat panel

1. AdminSidebar: removed yellow "Genera preventivo" CTA (duplicated Preventivi tab nav item)
2. Delete phase/task: new deletePhase/deleteTask server actions with FK cascade and phase-status recompute; DeletePhaseTaskButton client component with window.confirm; trash icons in PhasesTab per phase and per task
3. Public dashboard: extend activeOffers query with offer_macros join (offer_name, offer_type); reorder sidebar 1°Offerte 2°Pagamenti 3°Documenti; OffersSection shows macro public_name as heading (never tier letter/internal_name); PaymentStatus gains totalLabel/overrideAmount/hideRows props — retainer=monthly label + no Acconto/Saldo rows
4. Offer editor: Descrizione textarea (nota interna) and Modalità toggle moved directly under title; Tags section now contains only Categoria/Ticket/Tipo/Obiettivo
5. Basecamp chat: API route supports entity_type="phase" with authorization scoping; comments scope in client-view broadened to include phaseIds and client_id (general); CommentsTab updated with phase/general entities; ChatProvider React context; ChatPanel fixed slide-in panel with FAB (bottom-right) and composer tag selector (Generale + phases); PhaseCard gets chat-bubble icon pre-tagging that phase; inline ChatSection block removed from dashboard main column

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:11:55 +02:00
parent 4b28f254ba
commit 64030afef3
14 changed files with 733 additions and 260 deletions
+20 -1
View File
@@ -142,6 +142,14 @@ export async function updatePhaseStatus(phaseId: string, id: string, status: str
revalidatePath(path); revalidatePath(path);
} }
export async function deletePhase(phaseId: string, id: string) {
await requireAdmin();
// FK cascade on tasks and deliverables handles child rows
await db.delete(phases).where(eq(phases.id, phaseId));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// ── TASKS ───────────────────────────────────────────────────────────────────── // ── TASKS ─────────────────────────────────────────────────────────────────────
export async function addTask(phaseId: string, id: string, formData: FormData) { export async function addTask(phaseId: string, id: string, formData: FormData) {
@@ -205,6 +213,17 @@ export async function updateTaskStatus(taskId: string, id: string, status: strin
revalidatePath(path); revalidatePath(path);
} }
export async function deleteTask(taskId: string, id: string) {
await requireAdmin();
// Fetch phase_id before deletion so we can recompute phase status after
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
const phaseId = taskRow[0]?.phase_id;
await db.delete(tasks).where(eq(tasks.id, taskId));
if (phaseId) await recomputePhaseStatus(phaseId);
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// ── DELIVERABLES ────────────────────────────────────────────────────────────── // ── DELIVERABLES ──────────────────────────────────────────────────────────────
export async function addDeliverable(taskId: string, id: string, formData: FormData) { export async function addDeliverable(taskId: string, id: string, formData: FormData) {
@@ -337,7 +356,7 @@ export async function postAdminComment(id: string, formData: FormData) {
if (!body || !entity) throw new Error("Dati mancanti"); if (!body || !entity) throw new Error("Dati mancanti");
const [entity_type, entity_id] = entity.split(":"); const [entity_type, entity_id] = entity.split(":");
if (!entity_type || !entity_id) throw new Error("Formato entity non valido"); if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
const allowedTypes = ["task", "deliverable"]; const allowedTypes = ["task", "deliverable", "phase", "general"];
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido"); if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
const { path } = await resolveEntity(id); const { path } = await resolveEntity(id);
+7 -2
View File
@@ -7,7 +7,7 @@ import { rateLimit } from "@/lib/rate-limit";
const commentSchema = z.object({ const commentSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
entity_type: z.enum(["task", "deliverable", "general"]), entity_type: z.enum(["task", "deliverable", "general", "phase"]),
entity_id: z.string().min(1), entity_id: z.string().min(1),
body: z.string().min(1, "Il commento non può essere vuoto").max(2000), body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
}); });
@@ -76,7 +76,12 @@ export async function POST(request: NextRequest) {
.from(tasks) .from(tasks)
.where(inArray(tasks.phase_id, phaseIds)); .where(inArray(tasks.phase_id, phaseIds));
if (entity_type === "task") { if (entity_type === "phase") {
// Phase: entity_id must be one of the client's phases
if (!phasesForClient.find((p) => p.id === entity_id)) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
} else if (entity_type === "task") {
if (!taskRows.find((r) => r.id === entity_id)) { if (!taskRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
} }
-11
View File
@@ -39,17 +39,6 @@ export function AdminSidebar() {
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span> <span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
</div> </div>
{/* CTA globale — genera preventivo */}
<div className="px-3 py-3 border-b border-white/10">
<Link
href="/admin/preventivi/genera"
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-md text-xs font-semibold bg-[#DEF168] text-[#1A463C] hover:bg-[#d4e85e] transition-colors"
>
<FileText size={13} />
Genera preventivo
</Link>
</div>
{/* Nav links */} {/* Nav links */}
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5"> <nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => { {NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
@@ -0,0 +1,60 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deletePhase, deleteTask } from "@/app/admin/clients/[id]/actions";
interface DeletePhaseButtonProps {
type: "phase";
phaseId: string;
clientId: string;
}
interface DeleteTaskButtonProps {
type: "task";
taskId: string;
clientId: string;
}
type Props = DeletePhaseButtonProps | DeleteTaskButtonProps;
export function DeletePhaseTaskButton(props: Props) {
const [, startTransition] = useTransition();
const router = useRouter();
function handleClick() {
const label = props.type === "phase" ? "questa fase (e tutti i task/deliverable al suo interno)" : "questo task";
if (!window.confirm(`Eliminare ${label}? L'azione non è reversibile.`)) return;
startTransition(async () => {
if (props.type === "phase") {
await deletePhase(props.phaseId, props.clientId);
} else {
await deleteTask(props.taskId, props.clientId);
}
router.refresh();
});
}
return (
<button
type="button"
onClick={handleClick}
title={props.type === "phase" ? "Elimina fase" : "Elimina task"}
className="p-1 rounded text-[#999999] hover:text-red-500 hover:bg-red-50 transition-colors"
aria-label={props.type === "phase" ? "Elimina fase" : "Elimina task"}
>
{/* Trash icon */}
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
);
}
@@ -296,6 +296,42 @@ export function OfferEditorClient({
/> />
</h3> </h3>
</div> </div>
{/* Modalità toggle — directly under title */}
<div className="mt-3 flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
{([
{ value: "una_tantum", label: "Una tantum" },
{ value: "retainer", label: "Retainer" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => updateMacro("offer_type", opt.value)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
macro.offer_type === opt.value
? "bg-[#1A463C] text-white font-medium"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Descrizione — nota interna, non visibile al cliente */}
<div className="mt-3 flex items-start gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0 pt-2">Descrizione</span>
<textarea
value={macro.description}
onChange={(e) => updateMacro("description", e.target.value)}
placeholder="Descrizione interna dell'offerta (non visibile al cliente)..."
rows={3}
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30 text-[#1a1a1a] placeholder:text-[#aaaaaa]"
/>
</div>
</div> </div>
{/* Tags block */} {/* Tags block */}
@@ -324,29 +360,6 @@ export function OfferEditorClient({
/> />
</div> </div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
{([
{ value: "una_tantum", label: "Una tantum" },
{ value: "retainer", label: "Retainer" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => updateMacro("offer_type", opt.value)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
macro.offer_type === opt.value
? "bg-[#1A463C] text-white font-medium"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span> <span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
<OptionMultiSelect <OptionMultiSelect
+9 -5
View File
@@ -11,9 +11,12 @@ type Props = {
}; };
export async function CommentsTab({ comments, phases, clientId }: Props) { export async function CommentsTab({ comments, phases, clientId }: Props) {
// Build entity label map for display // Build entity label map for display (phases, tasks, deliverables, and general)
const entityLabels: Record<string, string> = {}; const entityLabels: Record<string, string> = {
[clientId]: "Messaggio generale",
};
for (const phase of phases) { for (const phase of phases) {
entityLabels[phase.id] = `Fase: ${phase.title}`;
for (const task of phase.tasks) { for (const task of phase.tasks) {
entityLabels[task.id] = `Task: ${task.title}`; entityLabels[task.id] = `Task: ${task.title}`;
for (const d of task.deliverables) { for (const d of task.deliverables) {
@@ -23,8 +26,11 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
} }
// Build list of entities the admin can reply on // Build list of entities the admin can reply on
const entities: Array<{ id: string; type: string; label: string }> = []; const entities: Array<{ id: string; type: string; label: string }> = [
{ id: clientId, type: "general", label: "Messaggio generale" },
];
for (const phase of phases) { for (const phase of phases) {
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
for (const task of phase.tasks) { for (const task of phase.tasks) {
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` }); entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
for (const d of task.deliverables) { for (const d of task.deliverables) {
@@ -67,7 +73,6 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
</div> </div>
{/* Admin reply form */} {/* Admin reply form */}
{entities.length > 0 && (
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
"use server"; "use server";
@@ -99,7 +104,6 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
Invia risposta Invia risposta
</Button> </Button>
</form> </form>
)}
</div> </div>
); );
} }
+7
View File
@@ -6,6 +6,7 @@ import {
} from "@/app/admin/clients/[id]/actions"; } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import type { ClientFullDetail } from "@/lib/admin-queries"; import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = { type Props = {
@@ -58,6 +59,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">{phase.title}</h3> <h3 className="font-semibold text-gray-900">{phase.title}</h3>
<div className="flex items-center gap-2">
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
"use server"; "use server";
@@ -84,6 +86,8 @@ export async function PhasesTab({ phases, clientId }: Props) {
Salva Salva
</Button> </Button>
</form> </form>
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
</div>
</div> </div>
{/* Tasks */} {/* Tasks */}
@@ -94,6 +98,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
className="flex items-center justify-between pl-3 border-l-2 border-gray-100" className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
> >
<span className="text-sm text-gray-800">{task.title}</span> <span className="text-sm text-gray-800">{task.title}</span>
<div className="flex items-center gap-1">
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
"use server"; "use server";
@@ -125,6 +130,8 @@ export async function PhasesTab({ phases, clientId }: Props) {
</Button> </Button>
</form> </form>
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
</div>
</div> </div>
))} ))}
</div> </div>
+35 -16
View File
@@ -7,8 +7,9 @@ import { DocumentsSection } from './documents-section';
import { NotesSection } from './notes-section'; import { NotesSection } from './notes-section';
import { TranscriptsSection } from './transcripts-section'; import { TranscriptsSection } from './transcripts-section';
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle'; import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
import { ChatSection } from './client/ChatSection';
import { OffersSection } from './client/OffersSection'; import { OffersSection } from './client/OffersSection';
import { ChatProvider } from './client/ChatProvider';
import { ChatPanel } from './client/ChatPanel';
interface ClientDashboardProps { interface ClientDashboardProps {
view: ClientView; view: ClientView;
@@ -17,7 +18,17 @@ interface ClientDashboardProps {
} }
export function ClientDashboard({ view, token, comments }: ClientDashboardProps) { export function ClientDashboard({ view, token, comments }: ClientDashboardProps) {
// Determine payment display mode based on active offers
const retainerOffer = view.activeOffers?.find((o) => o.offer_type === "retainer");
const hasRetainer = !!retainerOffer;
const paymentLabel = hasRetainer ? "Totale Pagamento Mensile" : "Totale Investimento";
const paymentOverrideAmount = hasRetainer && retainerOffer?.accepted_total
? retainerOffer.accepted_total
: undefined;
return ( return (
<ChatProvider phases={view.phases} clientId={view.client.id}>
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Header */} {/* Header */}
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10"> <header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
@@ -52,26 +63,40 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
{/* ── Sidebar sinistra ── */} {/* ── Sidebar sinistra ── */}
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1"> <aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
<div className="lg:sticky lg:top-[89px] space-y-6"> <div className="lg:sticky lg:top-[89px] space-y-6">
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Pagamenti</h2> {/* 1° Offerte Attive */}
<PaymentStatus accepted_total={view.client.accepted_total} payments={view.payments} />
</div>
{view.activeOffers && view.activeOffers.length > 0 && ( {view.activeOffers && view.activeOffers.length > 0 && (
<div> <div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2> <h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
<OffersSection offers={view.activeOffers} /> <OffersSection offers={view.activeOffers} />
</div> </div>
)} )}
{/* 2° Pagamenti */}
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Pagamenti</h2>
<PaymentStatus
accepted_total={view.client.accepted_total}
payments={view.payments}
totalLabel={paymentLabel}
overrideAmount={paymentOverrideAmount}
hideRows={hasRetainer}
/>
</div>
{/* 3° Documenti & File */}
<div> <div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Documenti & File</h2> <h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Documenti & File</h2>
<DocumentsSection documents={view.documents} /> <DocumentsSection documents={view.documents} />
</div> </div>
{view.notes.length > 0 && ( {view.notes.length > 0 && (
<div> <div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Note & Decisioni</h2> <h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Note & Decisioni</h2>
<NotesSection notes={view.notes} /> <NotesSection notes={view.notes} />
</div> </div>
)} )}
{view.transcripts.length > 0 && ( {view.transcripts.length > 0 && (
<div> <div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Transcript Chiamate</h2> <h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Transcript Chiamate</h2>
@@ -88,17 +113,7 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
phases={view.phases} phases={view.phases}
token={token} token={token}
/> />
{/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
{/* Chat revisioni */}
<div>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-4">Messaggi & Revisioni</h2>
<ChatSection
clientId={view.client.id}
phases={view.phases}
token={token}
comments={comments}
/>
</div>
</main> </main>
</div> </div>
@@ -111,6 +126,10 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
</p> </p>
</div> </div>
</footer> </footer>
{/* Floating chat panel — FAB + slide-in panel */}
<ChatPanel token={token} comments={comments} />
</div> </div>
</ChatProvider>
); );
} }
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Comment } from "@/db/schema";
import { useChatContext } from "./ChatProvider";
// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE
// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and
// postAdminComment in /src/app/admin/clients/[id]/actions.ts — send Resend email here.
function formatTime(ts: Date | string): string {
const d = typeof ts === "string" ? new Date(ts) : ts;
return d.toLocaleString("it-IT", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
interface ChatPanelProps {
token: string;
comments: Comment[];
}
export function ChatPanel({ token, comments }: ChatPanelProps) {
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
const bottomRef = useRef<HTMLDivElement>(null);
// Build entity list: Generale + phases
type EntityOption = { id: string; type: "general" | "phase"; label: string };
const entityOptions: EntityOption[] = [
{ id: clientId, type: "general", label: "Generale" },
...phases.map((p) => ({ id: p.id, type: "phase" as const, label: p.title })),
];
// Build label map for displaying tags on existing messages
const labelMap = new Map<string, string>();
labelMap.set(clientId, "Generale");
for (const p of phases) {
labelMap.set(p.id, p.title);
for (const t of p.tasks) {
labelMap.set(t.id, `${p.title}${t.title}`);
for (const d of t.deliverables) {
labelMap.set(d.id, `${t.title}${d.title}`);
}
}
}
// Current selection: sync with selectedPhaseId from context
const [currentEntityId, setCurrentEntityId] = useState<string>(clientId);
useEffect(() => {
setCurrentEntityId(selectedPhaseId ?? clientId);
}, [selectedPhaseId, clientId]);
// Sort all comments chronologically
const sorted = [...comments].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
useEffect(() => {
if (isOpen) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [isOpen, comments.length]);
function handleSend(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
if (!trimmed) return;
const selectedEntity = entityOptions.find((en) => en.id === currentEntityId);
if (!selectedEntity) return;
setError(null);
startTransition(async () => {
try {
const res = await fetch("/api/client/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
entity_type: selectedEntity.type,
entity_id: selectedEntity.id,
body: trimmed,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Errore nell'invio");
return;
}
setBody("");
router.refresh();
} catch {
setError("Errore di rete");
}
});
}
return (
<>
{/* FAB — floating action button bottom-right */}
<button
type="button"
onClick={() => (isOpen ? closeChat() : openChat())}
aria-label="Apri messaggi"
className="fixed bottom-6 right-6 z-40 w-14 h-14 rounded-full bg-[#1A463C] text-white shadow-lg hover:bg-[#163a31] transition-colors flex items-center justify-center"
>
{isOpen ? (
/* X close icon */
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
/* Chat bubble + plus SVG */
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</button>
{/* Overlay — subtle backdrop on mobile */}
{isOpen && (
<div
className="fixed inset-0 z-30 bg-black/10 lg:hidden"
onClick={closeChat}
aria-hidden="true"
/>
)}
{/* Slide-in panel */}
<div
className={`fixed top-0 right-0 h-full z-40 w-full sm:w-[420px] bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
aria-label="Messaggi & Revisioni"
role="dialog"
aria-modal="true"
>
{/* Panel header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[#e5e7eb] shrink-0">
<h2 className="text-base font-bold text-[#1a1a1a]">Messaggi & Revisioni</h2>
<button
type="button"
onClick={closeChat}
aria-label="Chiudi"
className="p-1.5 rounded-md text-[#71717a] hover:text-[#1a1a1a] hover:bg-[#f4f4f5] transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Chat feed */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{sorted.length === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-10">
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
</p>
)}
{sorted.map((c) => {
const isClient = c.author === "client";
const entityLabel = labelMap.get(c.entity_id);
const showTag = c.entity_id !== clientId && entityLabel;
return (
<div
key={c.id}
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
>
{/* Author + tag */}
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
isClient
? "bg-[#DEF168] text-[#1A463C]"
: "bg-[#1A463C] text-white"
}`}
>
{isClient ? "Tu" : "iamcavalli"}
</span>
{showTag && (
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[160px]">
{entityLabel}
</span>
)}
</div>
{/* Bubble */}
<div
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
isClient
? "bg-[#1A463C] text-white rounded-tr-sm"
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
}`}
>
{c.body}
</div>
{/* Timestamp */}
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
</div>
);
})}
<div ref={bottomRef} />
</div>
{/* Divider */}
<div className="border-t border-[#e5e7eb]" />
{/* Composer */}
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
{/* Tag selector: Generale + phases */}
<select
value={currentEntityId}
onChange={(e) => setCurrentEntityId(e.target.value)}
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
>
{entityOptions.map((en) => (
<option key={en.id} value={en.id}>
{en.label}
</option>
))}
</select>
<div className="flex gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend(e as unknown as React.FormEvent);
}
}}
placeholder="Scrivi un messaggio… (Invio per inviare)"
rows={2}
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
/>
<button
type="submit"
disabled={!body.trim()}
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
>
Invia
</button>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
</form>
</div>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import type { ClientView } from "@/lib/client-view";
interface ChatContextValue {
isOpen: boolean;
selectedPhaseId: string | null; // null = "Generale"
phases: ClientView["phases"];
clientId: string;
openChat: (phaseId?: string) => void;
closeChat: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
export function useChatContext() {
const ctx = useContext(ChatContext);
if (!ctx) throw new Error("useChatContext must be used inside ChatProvider");
return ctx;
}
export function ChatProvider({
children,
phases,
clientId,
}: {
children: React.ReactNode;
phases: ClientView["phases"];
clientId: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const openChat = useCallback((phaseId?: string) => {
setSelectedPhaseId(phaseId ?? null);
setIsOpen(true);
}, []);
const closeChat = useCallback(() => {
setIsOpen(false);
}, []);
return (
<ChatContext.Provider value={{ isOpen, selectedPhaseId, phases, clientId, openChat, closeChat }}>
{children}
</ChatContext.Provider>
);
}
+5 -2
View File
@@ -1,6 +1,8 @@
interface ActiveOffer { interface ActiveOffer {
id: string; id: string;
public_name: string; // micro offer public name only (security: see T-05-10) public_name: string; // micro offer public name (tier label) — NOT shown to client (T-05-10)
offer_name: string; // macro public_name — shown as heading
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of service prices cumulative_price: string; // sum of service prices
accepted_total: string | null; accepted_total: string | null;
} }
@@ -16,7 +18,8 @@ export function OffersSection({ offers }: OffersSectionProps) {
<div className="space-y-3"> <div className="space-y-3">
{offers.map((offer) => ( {offers.map((offer) => (
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4"> <div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.public_name}</p> {/* Show macro public_name as heading — never tier letter, never internal_name */}
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.offer_name}</p>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-[#71717a]">Valore incluso</span> <span className="text-xs text-[#71717a]">Valore incluso</span>
+16
View File
@@ -5,6 +5,7 @@ import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { ApproveButton } from "@/components/client/ApproveButton"; import { ApproveButton } from "@/components/client/ApproveButton";
import { useChatContext } from "@/components/client/ChatProvider";
import type { ClientView } from "@/lib/client-view"; import type { ClientView } from "@/lib/client-view";
type Phase = ClientView["phases"][number]; type Phase = ClientView["phases"][number];
@@ -75,6 +76,7 @@ export function PhaseCard({
defaultOpen: boolean; defaultOpen: boolean;
}) { }) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const { openChat } = useChatContext();
const doneCount = phase.tasks.filter((t) => t.status === "done").length; const doneCount = phase.tasks.filter((t) => t.status === "done").length;
return ( return (
@@ -95,6 +97,20 @@ export function PhaseCard({
<Badge className={`text-xs ${phaseStatusStyle[phase.status]}`}> <Badge className={`text-xs ${phaseStatusStyle[phase.status]}`}>
{phaseStatusLabel[phase.status]} {phaseStatusLabel[phase.status]}
</Badge> </Badge>
{/* Chat bubble — opens panel pre-tagged with this phase */}
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // don't toggle accordion
openChat(phase.id);
}}
aria-label="Apri chat per questa fase"
className="p-1 rounded-md text-[#999999] hover:text-[#1A463C] hover:bg-[#f0f7f4] transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<svg <svg
className={`w-4 h-4 text-[#999999] transition-transform ${open ? "rotate-180" : ""}`} className={`w-4 h-4 text-[#999999] transition-transform ${open ? "rotate-180" : ""}`}
fill="none" fill="none"
+20 -5
View File
@@ -5,6 +5,12 @@ import { Badge } from '@/components/ui/badge';
interface PaymentStatusProps { interface PaymentStatusProps {
accepted_total: string; accepted_total: string;
payments: ClientView['payments']; payments: ClientView['payments'];
/** Label for the total row. Defaults to "Totale Investimento" */
totalLabel?: string;
/** Override the displayed amount (retainer: show offer accepted_total, not project total) */
overrideAmount?: string;
/** When true, hide individual payment rows (Acconto/Saldo) — used for retainer offers */
hideRows?: boolean;
} }
type PaymentStatusValue = 'da_saldare' | 'inviata' | 'saldato'; type PaymentStatusValue = 'da_saldare' | 'inviata' | 'saldato';
@@ -30,8 +36,15 @@ const statusConfig: Record<
}, },
}; };
export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps) { export function PaymentStatus({
const totalFormatted = parseFloat(accepted_total || '0').toLocaleString('it-IT', { accepted_total,
payments,
totalLabel = 'Totale Investimento',
overrideAmount,
hideRows = false,
}: PaymentStatusProps) {
const displayAmount = overrideAmount ?? accepted_total;
const totalFormatted = parseFloat(displayAmount || '0').toLocaleString('it-IT', {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -40,17 +53,18 @@ export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps)
return ( return (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none overflow-hidden"> <Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none overflow-hidden">
{/* Totale accettato — unico importo visibile al cliente (LOCKED) */} {/* Totale — unico importo visibile al cliente (LOCKED) */}
<div className="px-6 py-5 border-b border-[#e5e5e5] bg-[#f9f9f9]"> <div className="px-6 py-5 border-b border-[#e5e5e5] bg-[#f9f9f9]">
<p className="text-xs font-semibold text-[#666666] uppercase tracking-wider mb-1"> <p className="text-xs font-semibold text-[#666666] uppercase tracking-wider mb-1">
Totale Preventivo Accettato {totalLabel}
</p> </p>
<p className="text-3xl font-bold text-[#1a1a1a] tracking-tight"> <p className="text-3xl font-bold text-[#1a1a1a] tracking-tight">
{totalFormatted} {totalFormatted}
</p> </p>
</div> </div>
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo */} {/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */}
{!hideRows && (
<div className="px-6 py-4 space-y-3"> <div className="px-6 py-4 space-y-3">
{payments.length === 0 ? ( {payments.length === 0 ? (
<p className="text-sm text-[#999999] italic py-2"> <p className="text-sm text-[#999999] italic py-2">
@@ -86,6 +100,7 @@ export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps)
}) })
)} )}
</div> </div>
)}
{/* Nota informativa */} {/* Nota informativa */}
<div className="px-6 py-3 border-t border-[#e5e5e5] bg-[#f9f9f9]"> <div className="px-6 py-3 border-t border-[#e5e5e5] bg-[#f9f9f9]">
+17 -3
View File
@@ -1,6 +1,6 @@
import { eq, inArray, asc, desc, sql } from "drizzle-orm"; import { eq, inArray, asc, desc, sql } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, clientTranscripts } from "@/db/schema"; import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, clientTranscripts } from "@/db/schema";
/** /**
* ClientView: Legacy shape used by ClientDashboard component. * ClientView: Legacy shape used by ClientDashboard component.
@@ -55,6 +55,8 @@ export interface ClientView {
activeOffers?: Array<{ activeOffers?: Array<{
id: string; id: string;
public_name: string; public_name: string;
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string; cumulative_price: string;
accepted_total: string | null; accepted_total: string | null;
}>; }>;
@@ -125,6 +127,8 @@ export interface ProjectView {
activeOffers?: Array<{ activeOffers?: Array<{
id: string; id: string;
public_name: string; // offer_micros.public_name — NEVER internal_name public_name: string; // offer_micros.public_name — NEVER internal_name
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of assigned offer_services.price cumulative_price: string; // sum of assigned offer_services.price
accepted_total: string | null; accepted_total: string | null;
}>; }>;
@@ -294,11 +298,14 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
.select({ .select({
id: project_offers.id, id: project_offers.id,
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
offer_type: offer_macros.offer_type, // una_tantum | retainer
accepted_total: project_offers.accepted_total, accepted_total: project_offers.accepted_total,
micro_id: project_offers.micro_id, micro_id: project_offers.micro_id,
}) })
.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, projectId)); .where(eq(project_offers.project_id, projectId));
// Cumulative price per micro (sum of assigned services) // Cumulative price per micro (sum of assigned services)
@@ -318,6 +325,8 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
const activeOffers = projectOfferRows.map((o) => ({ const activeOffers = projectOfferRows.map((o) => ({
id: o.id, id: o.id,
public_name: o.public_name, public_name: o.public_name,
offer_name: o.offer_name,
offer_type: o.offer_type,
cumulative_price: cumulMap.get(o.micro_id) ?? "0", cumulative_price: cumulMap.get(o.micro_id) ?? "0",
accepted_total: o.accepted_total ? String(o.accepted_total) : null, accepted_total: o.accepted_total ? String(o.accepted_total) : null,
})); }));
@@ -335,8 +344,13 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
.where(eq(clientTranscripts.client_id, project.client_id)) .where(eq(clientTranscripts.client_id, project.client_id))
.orderBy(desc(clientTranscripts.call_date)); .orderBy(desc(clientTranscripts.call_date));
// Comments scoped to tasks and deliverables of this project // Comments scoped to: general (client_id), phases, tasks, and deliverables of this project
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)]; const allEntityIds = [
project.client_id, // general messages entity_id = client_id
...phaseIds, // phase-tagged messages
...taskIds,
...deliverablesRows.map((d) => d.id),
];
const commentsRows = const commentsRows =
allEntityIds.length === 0 allEntityIds.length === 0
? [] ? []