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 { getClientView } from '@/lib/client-view';
|
||||||
import { ClientDashboard } from '@/components/client-dashboard';
|
import { ClientDashboard } from '@/components/client-dashboard';
|
||||||
import { notFound } from 'next/navigation';
|
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
|
// React cache deduplicates DB calls within the same render
|
||||||
const getCachedClientView = cache(getClientView);
|
const getCachedClientView = cache(getClientView);
|
||||||
@@ -38,5 +42,20 @@ export default async function ClientPage({
|
|||||||
notFound();
|
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 { ClientView } from '@/lib/client-view';
|
||||||
|
import type { Comment } from '@/db/schema';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { PhaseTimeline } from './phase-timeline';
|
import { PhaseTimeline } from './phase-timeline';
|
||||||
import { PaymentStatus } from './payment-status';
|
import { PaymentStatus } from './payment-status';
|
||||||
@@ -7,9 +8,11 @@ import { NotesSection } from './notes-section';
|
|||||||
|
|
||||||
interface ClientDashboardProps {
|
interface ClientDashboardProps {
|
||||||
view: ClientView;
|
view: ClientView;
|
||||||
|
token: string;
|
||||||
|
comments: Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientDashboard({ view }: ClientDashboardProps) {
|
export function ClientDashboard({ view, token, comments }: ClientDashboardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Header: logo iamcavalli (piccolo) + brand_name cliente (prominente) */}
|
{/* Header: logo iamcavalli (piccolo) + brand_name cliente (prominente) */}
|
||||||
@@ -54,10 +57,10 @@ export function ClientDashboard({ view }: ClientDashboardProps) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline fasi */}
|
{/* Timeline fasi — now with interactive ApproveButton + CommentForm/List */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Fasi del Progetto</h2>
|
<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>
|
</section>
|
||||||
|
|
||||||
{/* Stato pagamenti — sempre visibile (D-10) */}
|
{/* 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 { ClientView } from '@/lib/client-view';
|
||||||
|
import type { Comment } from '@/db/schema';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ApproveButton } from './client/ApproveButton';
|
||||||
|
import { CommentList } from './client/CommentList';
|
||||||
|
import { CommentForm } from './client/CommentForm';
|
||||||
|
|
||||||
interface PhaseTimelineProps {
|
interface PhaseTimelineProps {
|
||||||
phases: ClientView['phases'];
|
phases: ClientView['phases'];
|
||||||
|
token: string;
|
||||||
|
comments: Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
|
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',
|
done: 'border-transparent bg-[#16a34a] text-white',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PhaseTimeline({ phases }: PhaseTimelineProps) {
|
export function PhaseTimeline({ phases, token, comments }: PhaseTimelineProps) {
|
||||||
if (phases.length === 0) {
|
if (phases.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-[#999999] italic">
|
<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 (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{phases.map((phase, index) => {
|
{phases.map((phase, index) => {
|
||||||
@@ -174,7 +184,7 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
|
|||||||
Nessun task ancora configurato.
|
Nessun task ancora configurato.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-4">
|
||||||
{phase.tasks.map((task) => (
|
{phase.tasks.map((task) => (
|
||||||
<li key={task.id} className="flex items-start gap-2.5">
|
<li key={task.id} className="flex items-start gap-2.5">
|
||||||
<TaskStatusIcon status={task.status} />
|
<TaskStatusIcon status={task.status} />
|
||||||
@@ -194,26 +204,46 @@ export function PhaseTimeline({ phases }: PhaseTimelineProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Deliverable annidati */}
|
{/* Deliverable annidati con ApproveButton + CommentiDeliverable */}
|
||||||
{task.deliverables.length > 0 && (
|
{task.deliverables.length > 0 && (
|
||||||
<ul className="mt-1.5 space-y-1">
|
<ul className="mt-1.5 space-y-3">
|
||||||
{task.deliverables.map((d) => (
|
{task.deliverables.map((d) => (
|
||||||
<li
|
<li
|
||||||
key={d.id}
|
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">
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
{d.title}
|
<span className="text-xs text-[#666666] truncate font-medium">
|
||||||
</span>
|
{d.title}
|
||||||
{d.status === 'approved' && (
|
</span>
|
||||||
<Badge className="text-xs border-transparent bg-[#16a34a] text-white shrink-0">
|
{/* ApproveButton: shown for pending/submitted; shows date badge once approved */}
|
||||||
Approvato
|
{(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && (
|
||||||
</Badge>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Comments on the task itself */}
|
||||||
|
<CommentList comments={commentsFor(task.id)} />
|
||||||
|
<CommentForm
|
||||||
|
token={token}
|
||||||
|
entityType="task"
|
||||||
|
entityId={task.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user