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 */}
{/* 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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/client/CommentList.tsx b/src/components/client/CommentList.tsx
new file mode 100644
index 0000000..494d9c9
--- /dev/null
+++ b/src/components/client/CommentList.tsx
@@ -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 (
+
+ {comments.map((c) => (
+
+
+
+ {c.author === "admin" ? "iamcavalli" : "Tu"}
+
+
{c.body}
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/phase-timeline.tsx b/src/components/phase-timeline.tsx
index a7f46fb..c264aa0 100644
--- a/src/components/phase-timeline.tsx
+++ b/src/components/phase-timeline.tsx
@@ -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 (
@@ -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 (
{phases.map((phase, index) => {
@@ -174,7 +184,7 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
Nessun task ancora configurato.
) : (
-
+
{phase.tasks.map((task) => (
-
@@ -194,26 +204,46 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
)}
- {/* Deliverable annidati */}
+ {/* Deliverable annidati con ApproveButton + CommentiDeliverable */}
{task.deliverables.length > 0 && (
-
+
{task.deliverables.map((d) => (
-
-
- {d.title}
-
- {d.status === 'approved' && (
-
- Approvato
-
- )}
+
+
+ {d.title}
+
+ {/* ApproveButton: shown for pending/submitted; shows date badge once approved */}
+ {(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && (
+
+ )}
+
+ {/* Comments on this deliverable */}
+
+
))}
)}
+
+ {/* Comments on the task itself */}
+
+
))}