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
This commit is contained in:
Simone Cavalli
2026-05-15 21:50:07 +02:00
parent c24bdde603
commit dc512ec758
6 changed files with 236 additions and 18 deletions
+21 -2
View File
@@ -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 <ClientDashboard view={view} />;
// 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 <ClientDashboard view={view} token={token} comments={allComments} />;
}
+6 -3
View File
@@ -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 (
<div className="min-h-screen bg-white">
{/* Header: logo iamcavalli (piccolo) + brand_name cliente (prominente) */}
@@ -54,10 +57,10 @@ export function ClientDashboard({ view }: ClientDashboardProps) {
</section>
)}
{/* Timeline fasi */}
{/* Timeline fasi — now with interactive ApproveButton + CommentForm/List */}
<section>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Fasi del Progetto</h2>
<PhaseTimeline phases={view.phases} />
<PhaseTimeline phases={view.phases} token={token} comments={comments} />
</section>
{/* Stato pagamenti — sempre visibile (D-10) */}
+68
View File
@@ -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<string | null>(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 (
<span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded">
Approvato il {date}
</span>
);
}
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 (
<div>
<Button
size="sm"
variant="outline"
onClick={handleApprove}
disabled={loading}
className="text-xs text-green-700 border-green-300 hover:bg-green-50"
>
{loading ? "Approvazione..." : "Approva"}
</Button>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}
+67
View File
@@ -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<string | null>(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 (
<form onSubmit={handleSubmit} className="mt-3 flex gap-2 items-end">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Lascia un commento..."
rows={2}
className="text-sm resize-none flex-1"
/>
<div className="flex flex-col justify-end">
<Button
type="submit"
size="sm"
disabled={loading || !body.trim()}
className="text-xs"
>
{loading ? "Invio..." : "Invia"}
</Button>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</form>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Comment } from "@/db/schema";
type Props = { comments: Comment[] };
export function CommentList({ comments }: Props) {
if (comments.length === 0) return null;
return (
<div className="mt-3 space-y-2">
{comments.map((c) => (
<div
key={c.id}
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
>
<div
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
c.author === "admin"
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
<p className="font-medium mb-0.5 opacity-60">
{c.author === "admin" ? "iamcavalli" : "Tu"}
</p>
<p>{c.body}</p>
</div>
</div>
))}
</div>
);
}
+43 -13
View File
@@ -1,10 +1,16 @@
import type { ClientView } from '@/lib/client-view';
import type { Comment } from '@/db/schema';
import { Progress } from '@/components/ui/progress';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ApproveButton } from './client/ApproveButton';
import { CommentList } from './client/CommentList';
import { CommentForm } from './client/CommentForm';
interface PhaseTimelineProps {
phases: ClientView['phases'];
token: string;
comments: Comment[];
}
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
@@ -113,7 +119,7 @@ const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
done: 'border-transparent bg-[#16a34a] text-white',
};
export function PhaseTimeline({ phases }: PhaseTimelineProps) {
export function PhaseTimeline({ phases, token, comments }: PhaseTimelineProps) {
if (phases.length === 0) {
return (
<p className="text-sm text-[#999999] italic">
@@ -122,6 +128,10 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
);
}
// Helper: filter pre-fetched comments by entity id
const commentsFor = (entityId: string) =>
comments.filter((c) => c.entity_id === entityId);
return (
<div className="space-y-0">
{phases.map((phase, index) => {
@@ -174,7 +184,7 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
Nessun task ancora configurato.
</p>
) : (
<ul className="space-y-2">
<ul className="space-y-4">
{phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-2.5">
<TaskStatusIcon status={task.status} />
@@ -194,26 +204,46 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
</p>
)}
{/* Deliverable annidati */}
{/* Deliverable annidati con ApproveButton + CommentiDeliverable */}
{task.deliverables.length > 0 && (
<ul className="mt-1.5 space-y-1">
<ul className="mt-1.5 space-y-3">
{task.deliverables.map((d) => (
<li
key={d.id}
className="flex items-center justify-between gap-2 bg-[#f9f9f9] rounded px-2 py-1"
className="bg-[#f9f9f9] rounded px-3 py-2"
>
<span className="text-xs text-[#666666] truncate">
{d.title}
</span>
{d.status === 'approved' && (
<Badge className="text-xs border-transparent bg-[#16a34a] text-white shrink-0">
Approvato
</Badge>
)}
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs text-[#666666] truncate font-medium">
{d.title}
</span>
{/* ApproveButton: shown for pending/submitted; shows date badge once approved */}
{(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && (
<ApproveButton
deliverableId={d.id}
token={token}
approvedAt={d.approved_at}
/>
)}
</div>
{/* Comments on this deliverable */}
<CommentList comments={commentsFor(d.id)} />
<CommentForm
token={token}
entityType="deliverable"
entityId={d.id}
/>
</li>
))}
</ul>
)}
{/* Comments on the task itself */}
<CommentList comments={commentsFor(task.id)} />
<CommentForm
token={token}
entityType="task"
entityId={task.id}
/>
</div>
</li>
))}