From dc512ec758e422077666ea292ed64031f06181db Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 15 May 2026 21:50:07 +0200 Subject: [PATCH] feat(02-04): add ApproveButton, CommentForm, CommentList; wire interactive elements into client dashboard - ApproveButton: 'use client', POSTs to /api/client/approve with token + deliverableId, calls router.refresh(); shows immutable "Approvato il [date]" badge once approved_at is set - CommentForm: 'use client', POSTs to /api/client/comment, calls router.refresh() on success; clears textarea after submit - CommentList: presentational Server Component, labels client author as "Tu" and admin as "iamcavalli" - page.tsx: fetches all comments server-side (scoped to client's task/deliverable ids), passes token + comments to ClientDashboard; revalidate=0 ensures approvals and comments always fresh - client-dashboard.tsx: passes token + comments down to PhaseTimeline - phase-timeline.tsx: renders ApproveButton on each deliverable (pending/submitted/approved), CommentList + CommentForm below each deliverable and each task --- src/app/c/[token]/page.tsx | 23 ++++++++- src/components/client-dashboard.tsx | 9 ++-- src/components/client/ApproveButton.tsx | 68 +++++++++++++++++++++++++ src/components/client/CommentForm.tsx | 67 ++++++++++++++++++++++++ src/components/client/CommentList.tsx | 31 +++++++++++ src/components/phase-timeline.tsx | 56 +++++++++++++++----- 6 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 src/components/client/ApproveButton.tsx create mode 100644 src/components/client/CommentForm.tsx create mode 100644 src/components/client/CommentList.tsx diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 1133da4..545d612 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -2,8 +2,12 @@ import { cache } from 'react'; import { getClientView } from '@/lib/client-view'; import { ClientDashboard } from '@/components/client-dashboard'; import { notFound } from 'next/navigation'; +import { db } from '@/db'; +import { comments } from '@/db/schema'; +import { inArray } from 'drizzle-orm'; +import type { Comment } from '@/db/schema'; -export const revalidate = 60; // ISR: revalidate ogni 60 secondi +export const revalidate = 0; // Always revalidate — comments and approvals must be fresh // React cache deduplicates DB calls within the same render const getCachedClientView = cache(getClientView); @@ -38,5 +42,20 @@ export default async function ClientPage({ notFound(); } - return ; + // Fetch comments for all tasks and deliverables in this client's data + const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id)); + const allDeliverableIds = view.phases.flatMap((p) => + p.tasks.flatMap((t) => t.deliverables.map((d) => d.id)) + ); + const allEntityIds = [...allTaskIds, ...allDeliverableIds]; + + const allComments: Comment[] = + allEntityIds.length > 0 + ? await db + .select() + .from(comments) + .where(inArray(comments.entity_id, allEntityIds)) + : []; + + return ; } \ No newline at end of file diff --git a/src/components/client-dashboard.tsx b/src/components/client-dashboard.tsx index c5b7390..1605949 100644 --- a/src/components/client-dashboard.tsx +++ b/src/components/client-dashboard.tsx @@ -1,4 +1,5 @@ import type { ClientView } from '@/lib/client-view'; +import type { Comment } from '@/db/schema'; import { Progress } from '@/components/ui/progress'; import { PhaseTimeline } from './phase-timeline'; import { PaymentStatus } from './payment-status'; @@ -7,9 +8,11 @@ import { NotesSection } from './notes-section'; interface ClientDashboardProps { view: ClientView; + token: string; + comments: Comment[]; } -export function ClientDashboard({ view }: ClientDashboardProps) { +export function ClientDashboard({ view, token, comments }: ClientDashboardProps) { return (
{/* Header: logo iamcavalli (piccolo) + brand_name cliente (prominente) */} @@ -54,10 +57,10 @@ export function ClientDashboard({ view }: ClientDashboardProps) { )} - {/* Timeline fasi */} + {/* Timeline fasi — now with interactive ApproveButton + CommentForm/List */}

Fasi del Progetto

- +
{/* Stato pagamenti — sempre visibile (D-10) */} diff --git a/src/components/client/ApproveButton.tsx b/src/components/client/ApproveButton.tsx new file mode 100644 index 0000000..18f58d1 --- /dev/null +++ b/src/components/client/ApproveButton.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +type Props = { + deliverableId: string; + token: string; + approvedAt: string | null; // ISO timestamp or null +}; + +export function ApproveButton({ deliverableId, token, approvedAt }: Props) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Already approved — show immutable confirmation, no button + if (approvedAt !== null) { + const date = new Date(approvedAt).toLocaleDateString("it-IT", { + day: "2-digit", + month: "long", + year: "numeric", + }); + return ( + + Approvato il {date} + + ); + } + + async function handleApprove() { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/client/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, deliverableId }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Errore durante l'approvazione"); + return; + } + router.refresh(); // Re-fetch Server Component data — approved_at now set + } catch { + setError("Errore di rete"); + } finally { + setLoading(false); + } + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} \ No newline at end of file diff --git a/src/components/client/CommentForm.tsx b/src/components/client/CommentForm.tsx new file mode 100644 index 0000000..d750d50 --- /dev/null +++ b/src/components/client/CommentForm.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +type Props = { + token: string; + entityType: "task" | "deliverable"; + entityId: string; +}; + +export function CommentForm({ token, entityType, entityId }: Props) { + const router = useRouter(); + const [body, setBody] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!body.trim()) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/client/comment", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Errore durante l'invio"); + return; + } + setBody(""); + router.refresh(); // Re-fetch Server Component to show new comment + } catch { + setError("Errore di rete"); + } finally { + setLoading(false); + } + } + + return ( +
+