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:
@@ -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,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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
? []
|
? []
|
||||||
|
|||||||
Reference in New Issue
Block a user