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:
@@ -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} />;
|
||||
}
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user