64030afef3
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>
261 lines
9.5 KiB
TypeScript
261 lines
9.5 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|