feat: unify client messaging into ChatSection + add general comment type

Removed per-task/deliverable CommentList/CommentForm from PhaseTimeline and
ClientKanban. Replaced with a single ChatSection at the bottom of the dashboard
that handles general, task, and deliverable messages in a unified chat UI.
Added "general" entity_type to the comment API (entity_id = client UUID).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simone Cavalli
2026-05-16 12:49:14 +02:00
parent c467ef300b
commit 549cf0b592
7 changed files with 307 additions and 348 deletions
+33 -142
View File
@@ -1,185 +1,76 @@
"use client";
import { useState } from "react";
import type { ClientView } from "@/lib/client-view";
import type { Comment } from "@/db/schema";
import { ApproveButton } from "@/components/client/ApproveButton";
import { CommentList } from "@/components/client/CommentList";
import { CommentForm } from "@/components/client/CommentForm";
type Task = ClientView["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string; dotClass: string; headerClass: string }[] = [
{
id: "todo",
label: "Da fare",
dotClass: "bg-[#d4d4d8]",
headerClass: "text-[#71717a]",
},
{
id: "in_progress",
label: "In corso",
dotClass: "bg-[#DEF168]",
headerClass: "text-[#1A463C]",
},
{
id: "done",
label: "Fatto",
dotClass: "bg-[#1A463C]",
headerClass: "text-[#1A463C]",
},
{ id: "todo", label: "Da fare", dotClass: "bg-[#d4d4d8]", headerClass: "text-[#71717a]" },
{ id: "in_progress", label: "In corso", dotClass: "bg-[#DEF168]", headerClass: "text-[#1A463C]" },
{ id: "done", label: "Fatto", dotClass: "bg-[#1A463C]", headerClass: "text-[#1A463C]" },
];
function TaskCard({
task,
token,
comments,
}: {
task: Task;
token: string;
comments: Comment[];
}) {
const [expanded, setExpanded] = useState(false);
const taskComments = comments.filter((c) => c.entity_id === task.id);
const hasDeliverables = task.deliverables.length > 0;
const hasActivity = taskComments.length > 0 || hasDeliverables;
function TaskCard({ task, token }: { task: Task; token: string }) {
return (
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden shadow-sm">
<button
onClick={() => setExpanded((v) => !v)}
className="w-full text-left px-3 py-2.5 hover:bg-[#f9f9f9] transition-colors"
>
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</p>
<div className="flex items-start justify-between gap-2">
<p
className={`text-sm font-medium leading-snug ${
task.status === "done"
? "line-through text-[#71717a]"
: "text-[#1a1a1a]"
}`}
>
{task.title}
</p>
{hasActivity && (
<span className="text-[10px] text-[#71717a] mt-0.5 shrink-0">
{expanded ? "▲" : "▼"}
</span>
)}
</div>
{task.description && !expanded && (
<p className="text-xs text-[#71717a] mt-0.5 leading-snug line-clamp-2">
{task.description}
</p>
)}
</button>
{expanded && (
<div className="border-t border-[#f4f4f5] px-3 pb-3 pt-2 space-y-3">
{task.description && (
<p className="text-xs text-[#71717a] leading-snug">{task.description}</p>
)}
{hasDeliverables && (
<div className="space-y-2">
{task.deliverables.map((d) => {
const delivComments = comments.filter((c) => c.entity_id === d.id);
return (
<div
key={d.id}
className="bg-[#f9f9f9] rounded-lg px-3 py-2 space-y-2"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-[#1a1a1a] truncate">
{d.title}
</span>
{(d.status === "pending" ||
d.status === "submitted" ||
d.approved_at !== null) && (
<ApproveButton
deliverableId={d.id}
token={token}
approvedAt={d.approved_at}
/>
)}
</div>
<CommentList comments={delivComments} />
<CommentForm
token={token}
entityType="deliverable"
entityId={d.id}
/>
</div>
);
})}
</div>
)}
<CommentList comments={taskComments} />
<CommentForm token={token} entityType="task" entityId={task.id} />
</div>
<div className="bg-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 shadow-sm">
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</p>
<p className={`text-sm font-medium leading-snug mb-2 ${task.status === "done" ? "line-through text-[#71717a]" : "text-[#1a1a1a]"}`}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-[#71717a] mb-2 leading-snug">{task.description}</p>
)}
{task.deliverables.length > 0 && (
<ul className="space-y-1.5 mt-2 pt-2 border-t border-[#f4f4f5]">
{task.deliverables.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2">
<span className="text-xs text-[#666666] truncate">{d.title}</span>
{(d.status === "pending" || d.status === "submitted" || d.approved_at !== null) && (
<ApproveButton deliverableId={d.id} token={token} approvedAt={d.approved_at} />
)}
</li>
))}
</ul>
)}
</div>
);
}
export function ClientKanban({
phases,
token,
comments,
}: {
phases: ClientView["phases"];
token: string;
comments: Comment[];
}) {
export function ClientKanban({ phases, token }: { phases: ClientView["phases"]; token: string }) {
const allTasks: Task[] = phases.flatMap((phase) =>
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
);
const tasksByStatus = {
todo: allTasks.filter((t) => t.status === "todo"),
todo: allTasks.filter((t) => t.status === "todo"),
in_progress: allTasks.filter((t) => t.status === "in_progress"),
done: allTasks.filter((t) => t.status === "done"),
done: allTasks.filter((t) => t.status === "done"),
};
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{COLUMNS.map((col) => (
<div
key={col.id}
className="flex flex-col rounded-xl border border-[#e5e7eb] bg-[#f9f9f9] min-h-[200px]"
>
<div
className={`px-4 py-3 flex items-center justify-between ${col.headerClass}`}
>
<div key={col.id} className="flex flex-col rounded-xl border border-[#e5e7eb] bg-[#f9f9f9] min-h-[200px]">
<div className={`px-4 py-3 flex items-center justify-between ${col.headerClass}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${col.dotClass}`} />
<span className="text-xs font-bold uppercase tracking-wider">
{col.label}
</span>
<span className="text-xs font-bold uppercase tracking-wider">{col.label}</span>
</div>
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
{tasksByStatus[col.id].length}
</span>
</div>
<div className="flex-1 p-3 space-y-2">
{tasksByStatus[col.id].map((task) => (
<TaskCard
key={task.id}
task={task}
token={token}
comments={comments}
/>
<TaskCard key={task.id} task={task} token={token} />
))}
{tasksByStatus[col.id].length === 0 && (
<p className="text-xs text-[#d4d4d8] italic text-center py-10">
Nessun task
</p>
<p className="text-xs text-[#d4d4d8] italic text-center py-10">Nessun task</p>
)}
</div>
</div>