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:
@@ -6,7 +6,7 @@ import { clients, comments, tasks, phases, deliverables } from "@/db/schema";
|
|||||||
|
|
||||||
const commentSchema = z.object({
|
const commentSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
entity_type: z.enum(["task", "deliverable"]),
|
entity_type: z.enum(["task", "deliverable", "general"]),
|
||||||
entity_id: z.string().min(1),
|
entity_id: z.string().min(1),
|
||||||
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
|
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
|
||||||
});
|
});
|
||||||
@@ -38,59 +38,50 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const clientId = clientRows[0].id;
|
const clientId = clientRows[0].id;
|
||||||
|
|
||||||
// Verify entity belongs to this client (prevent cross-client comment injection)
|
if (entity_type === "general") {
|
||||||
if (entity_type === "task") {
|
// General messages: entity_id must be the client's own id
|
||||||
|
if (entity_id !== clientId) {
|
||||||
|
return NextResponse.json({ error: "Entity non valida" }, { status: 403 });
|
||||||
|
}
|
||||||
|
} else if (entity_type === "task") {
|
||||||
const phasesForClient = await db
|
const phasesForClient = await db
|
||||||
.select({ id: phases.id })
|
.select({ id: phases.id })
|
||||||
.from(phases)
|
.from(phases)
|
||||||
.where(eq(phases.client_id, clientId));
|
.where(eq(phases.client_id, clientId));
|
||||||
const phaseIds = phasesForClient.map((p) => p.id);
|
const phaseIds = phasesForClient.map((p) => p.id);
|
||||||
|
|
||||||
if (phaseIds.length === 0) {
|
if (phaseIds.length === 0) {
|
||||||
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskRows = await db
|
const taskRows = await db
|
||||||
.select({ id: tasks.id })
|
.select({ id: tasks.id })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(inArray(tasks.phase_id, phaseIds));
|
.where(inArray(tasks.phase_id, phaseIds));
|
||||||
|
if (!taskRows.find((r) => r.id === entity_id)) {
|
||||||
const taskCheck = taskRows.find((r) => r.id === entity_id);
|
|
||||||
|
|
||||||
if (!taskCheck) {
|
|
||||||
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
|
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// deliverable — verify via task → phase → client chain
|
// deliverable
|
||||||
const phasesForClient = await db
|
const phasesForClient = await db
|
||||||
.select({ id: phases.id })
|
.select({ id: phases.id })
|
||||||
.from(phases)
|
.from(phases)
|
||||||
.where(eq(phases.client_id, clientId));
|
.where(eq(phases.client_id, clientId));
|
||||||
const phaseIds = phasesForClient.map((p) => p.id);
|
const phaseIds = phasesForClient.map((p) => p.id);
|
||||||
|
|
||||||
if (phaseIds.length === 0) {
|
if (phaseIds.length === 0) {
|
||||||
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskRows = await db
|
const taskRows = await db
|
||||||
.select({ id: tasks.id })
|
.select({ id: tasks.id })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(inArray(tasks.phase_id, phaseIds));
|
.where(inArray(tasks.phase_id, phaseIds));
|
||||||
|
|
||||||
const taskIds = taskRows.map((r) => r.id);
|
const taskIds = taskRows.map((r) => r.id);
|
||||||
|
|
||||||
if (taskIds.length === 0) {
|
if (taskIds.length === 0) {
|
||||||
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
|
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const delivRows = await db
|
const delivRows = await db
|
||||||
.select({ id: deliverables.id })
|
.select({ id: deliverables.id })
|
||||||
.from(deliverables)
|
.from(deliverables)
|
||||||
.where(inArray(deliverables.task_id, taskIds));
|
.where(inArray(deliverables.task_id, taskIds));
|
||||||
|
if (!delivRows.find((r) => r.id === entity_id)) {
|
||||||
const delivCheck = delivRows.find((r) => r.id === entity_id);
|
|
||||||
|
|
||||||
if (!delivCheck) {
|
|
||||||
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ export default async function ClientPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch comments for all tasks and deliverables in this client's data
|
// Fetch comments: tasks, deliverables, and general (entity_id = clientId)
|
||||||
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
|
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
|
||||||
const allDeliverableIds = view.phases.flatMap((p) =>
|
const allDeliverableIds = view.phases.flatMap((p) =>
|
||||||
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
|
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
|
||||||
);
|
);
|
||||||
const allEntityIds = [...allTaskIds, ...allDeliverableIds];
|
const allEntityIds = [view.client.id, ...allTaskIds, ...allDeliverableIds];
|
||||||
|
|
||||||
const allComments: Comment[] =
|
const allComments: Comment[] =
|
||||||
allEntityIds.length > 0
|
allEntityIds.length > 0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PaymentStatus } from './payment-status';
|
|||||||
import { DocumentsSection } from './documents-section';
|
import { DocumentsSection } from './documents-section';
|
||||||
import { NotesSection } from './notes-section';
|
import { NotesSection } from './notes-section';
|
||||||
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
||||||
|
import { ChatSection } from './client/ChatSection';
|
||||||
|
|
||||||
interface ClientDashboardProps {
|
interface ClientDashboardProps {
|
||||||
view: ClientView;
|
view: ClientView;
|
||||||
@@ -42,39 +43,24 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Layout principale: sidebar sinistra + contenuto destro */}
|
{/* Layout principale */}
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
|
||||||
{/* ── Sidebar sinistra ── */}
|
{/* ── Sidebar sinistra ── */}
|
||||||
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
|
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
|
||||||
<div className="lg:sticky lg:top-[89px] space-y-6">
|
<div className="lg:sticky lg:top-[89px] space-y-6">
|
||||||
|
|
||||||
{/* Pagamenti */}
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Pagamenti</h2>
|
||||||
Pagamenti
|
<PaymentStatus accepted_total={view.client.accepted_total} payments={view.payments} />
|
||||||
</h2>
|
|
||||||
<PaymentStatus
|
|
||||||
accepted_total={view.client.accepted_total}
|
|
||||||
payments={view.payments}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Documenti — sempre mostrati in sidebar (anche vuoti) */}
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Documenti & File</h2>
|
||||||
Documenti & File
|
|
||||||
</h2>
|
|
||||||
<DocumentsSection documents={view.documents} />
|
<DocumentsSection documents={view.documents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note — solo se presenti */}
|
|
||||||
{view.notes.length > 0 && (
|
{view.notes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Note & Decisioni</h2>
|
||||||
Note & Decisioni
|
|
||||||
</h2>
|
|
||||||
<NotesSection notes={view.notes} />
|
<NotesSection notes={view.notes} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -82,29 +68,34 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── Contenuto principale ── */}
|
{/* ── Contenuto principale ── */}
|
||||||
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-10">
|
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
|
||||||
{/* Brief progetto */}
|
|
||||||
{view.client.brief && (
|
{view.client.brief && (
|
||||||
<p className="text-base text-[#666666] italic leading-relaxed border-l-4 border-[#DEF168] pl-4">
|
<p className="text-base text-[#666666] italic leading-relaxed border-l-4 border-[#DEF168] pl-4">
|
||||||
{view.client.brief}
|
{view.client.brief}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fasi — toggle timeline/kanban */}
|
|
||||||
<PhaseViewToggle
|
<PhaseViewToggle
|
||||||
timelineView={
|
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
|
||||||
<PhaseTimeline phases={view.phases} token={token} comments={comments} />
|
phases={view.phases}
|
||||||
}
|
token={token}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Chat revisioni */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-[#1a1a1a] mb-4">Messaggi & Revisioni</h2>
|
||||||
|
<ChatSection
|
||||||
|
clientId={view.client.id}
|
||||||
phases={view.phases}
|
phases={view.phases}
|
||||||
token={token}
|
token={token}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
|
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||||
<p className="text-xs text-[#999999] text-center">
|
<p className="text-xs text-[#999999] text-center">
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition, useRef, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { ClientView } from "@/lib/client-view";
|
||||||
|
import type { Comment } from "@/db/schema";
|
||||||
|
|
||||||
|
type Entity = { id: string; type: "general" | "task" | "deliverable"; label: string };
|
||||||
|
|
||||||
|
function buildEntityList(clientId: string, phases: ClientView["phases"]): Entity[] {
|
||||||
|
const entities: Entity[] = [
|
||||||
|
{ id: clientId, type: "general", label: "Messaggio generale" },
|
||||||
|
];
|
||||||
|
for (const phase of phases) {
|
||||||
|
for (const task of phase.tasks) {
|
||||||
|
entities.push({
|
||||||
|
id: task.id,
|
||||||
|
type: "task",
|
||||||
|
label: `${phase.title} — ${task.title}`,
|
||||||
|
});
|
||||||
|
for (const d of task.deliverables) {
|
||||||
|
entities.push({
|
||||||
|
id: d.id,
|
||||||
|
type: "deliverable",
|
||||||
|
label: `${task.title} — ${d.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabelMap(entities: Entity[]): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const e of entities) map.set(e.id, e.label);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: Date | string): string {
|
||||||
|
const d = typeof ts === "string" ? new Date(ts) : ts;
|
||||||
|
return d.toLocaleString("it-IT", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSection({
|
||||||
|
clientId,
|
||||||
|
phases,
|
||||||
|
token,
|
||||||
|
comments,
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
phases: ClientView["phases"];
|
||||||
|
token: string;
|
||||||
|
comments: Comment[];
|
||||||
|
}) {
|
||||||
|
const entities = buildEntityList(clientId, phases);
|
||||||
|
const labelMap = buildLabelMap(entities);
|
||||||
|
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [selectedEntityId, setSelectedEntityId] = useState(clientId);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sort all comments chronologically
|
||||||
|
const sorted = [...comments].sort(
|
||||||
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [comments.length]);
|
||||||
|
|
||||||
|
function handleSend(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = body.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const selectedEntity = entities.find((en) => en.id === selectedEntityId);
|
||||||
|
if (!selectedEntity) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/client/comment", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
entity_type: selectedEntity.type,
|
||||||
|
entity_id: selectedEntity.id,
|
||||||
|
body: trimmed,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error ?? "Errore nell'invio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBody("");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError("Errore di rete");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col rounded-xl border border-[#e5e7eb] overflow-hidden bg-white">
|
||||||
|
{/* Chat feed */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 max-h-[480px] min-h-[200px]">
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<p className="text-sm text-[#71717a] italic text-center py-10">
|
||||||
|
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const isClient = c.author === "client";
|
||||||
|
const entityLabel = labelMap.get(c.entity_id);
|
||||||
|
const showTag = c.entity_id !== clientId && entityLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
|
||||||
|
>
|
||||||
|
{/* Author + tag */}
|
||||||
|
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
||||||
|
isClient
|
||||||
|
? "bg-[#DEF168] text-[#1A463C]"
|
||||||
|
: "bg-[#1A463C] text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isClient ? "Tu" : "iamcavalli"}
|
||||||
|
</span>
|
||||||
|
{showTag && (
|
||||||
|
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[180px]">
|
||||||
|
{entityLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bubble */}
|
||||||
|
<div
|
||||||
|
className={`max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||||
|
isClient
|
||||||
|
? "bg-[#1A463C] text-white rounded-tr-sm"
|
||||||
|
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-[#e5e7eb]" />
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa]">
|
||||||
|
{/* Entity selector */}
|
||||||
|
<select
|
||||||
|
value={selectedEntityId}
|
||||||
|
onChange={(e) => setSelectedEntityId(e.target.value)}
|
||||||
|
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||||
|
>
|
||||||
|
{entities.map((en) => (
|
||||||
|
<option key={en.id} value={en.id}>
|
||||||
|
{en.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend(e as unknown as React.FormEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Scrivi un messaggio… (Invio per inviare)"
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!body.trim()}
|
||||||
|
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
|
||||||
|
>
|
||||||
|
Invia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,141 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
import type { Comment } from "@/db/schema";
|
|
||||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
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] & {
|
type Task = ClientView["phases"][number]["tasks"][number] & {
|
||||||
phaseTitle: string;
|
phaseTitle: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string; dotClass: string; headerClass: 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: "todo",
|
{ id: "in_progress", label: "In corso", dotClass: "bg-[#DEF168]", headerClass: "text-[#1A463C]" },
|
||||||
label: "Da fare",
|
{ id: "done", label: "Fatto", dotClass: "bg-[#1A463C]", headerClass: "text-[#1A463C]" },
|
||||||
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({
|
function TaskCard({ task, token }: { task: Task; token: string }) {
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden shadow-sm">
|
<div className="bg-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 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">
|
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
|
||||||
{task.phaseTitle}
|
{task.phaseTitle}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<p className={`text-sm font-medium leading-snug mb-2 ${task.status === "done" ? "line-through text-[#71717a]" : "text-[#1a1a1a]"}`}>
|
||||||
<p
|
|
||||||
className={`text-sm font-medium leading-snug ${
|
|
||||||
task.status === "done"
|
|
||||||
? "line-through text-[#71717a]"
|
|
||||||
: "text-[#1a1a1a]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</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 && (
|
{task.description && (
|
||||||
<p className="text-xs text-[#71717a] leading-snug">{task.description}</p>
|
<p className="text-xs text-[#71717a] mb-2 leading-snug">{task.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{task.deliverables.length > 0 && (
|
||||||
{hasDeliverables && (
|
<ul className="space-y-1.5 mt-2 pt-2 border-t border-[#f4f4f5]">
|
||||||
<div className="space-y-2">
|
{task.deliverables.map((d) => (
|
||||||
{task.deliverables.map((d) => {
|
<li key={d.id} className="flex items-center justify-between gap-2">
|
||||||
const delivComments = comments.filter((c) => c.entity_id === d.id);
|
<span className="text-xs text-[#666666] truncate">{d.title}</span>
|
||||||
return (
|
{(d.status === "pending" || d.status === "submitted" || d.approved_at !== null) && (
|
||||||
<div
|
<ApproveButton deliverableId={d.id} token={token} approvedAt={d.approved_at} />
|
||||||
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>
|
</li>
|
||||||
<CommentList comments={delivComments} />
|
))}
|
||||||
<CommentForm
|
</ul>
|
||||||
token={token}
|
|
||||||
entityType="deliverable"
|
|
||||||
entityId={d.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CommentList comments={taskComments} />
|
|
||||||
<CommentForm token={token} entityType="task" entityId={task.id} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientKanban({
|
export function ClientKanban({ phases, token }: { phases: ClientView["phases"]; token: string }) {
|
||||||
phases,
|
|
||||||
token,
|
|
||||||
comments,
|
|
||||||
}: {
|
|
||||||
phases: ClientView["phases"];
|
|
||||||
token: string;
|
|
||||||
comments: Comment[];
|
|
||||||
}) {
|
|
||||||
const allTasks: Task[] = phases.flatMap((phase) =>
|
const allTasks: Task[] = phases.flatMap((phase) =>
|
||||||
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
||||||
);
|
);
|
||||||
@@ -149,37 +55,22 @@ export function ClientKanban({
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<div
|
<div key={col.id} className="flex flex-col rounded-xl border border-[#e5e7eb] bg-[#f9f9f9] min-h-[200px]">
|
||||||
key={col.id}
|
<div className={`px-4 py-3 flex items-center justify-between ${col.headerClass}`}>
|
||||||
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-2 h-2 rounded-full ${col.dotClass}`} />
|
<span className={`w-2 h-2 rounded-full ${col.dotClass}`} />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider">
|
<span className="text-xs font-bold uppercase tracking-wider">{col.label}</span>
|
||||||
{col.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
||||||
{tasksByStatus[col.id].length}
|
{tasksByStatus[col.id].length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 p-3 space-y-2">
|
<div className="flex-1 p-3 space-y-2">
|
||||||
{tasksByStatus[col.id].map((task) => (
|
{tasksByStatus[col.id].map((task) => (
|
||||||
<TaskCard
|
<TaskCard key={task.id} task={task} token={token} />
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
token={token}
|
|
||||||
comments={comments}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{tasksByStatus[col.id].length === 0 && (
|
{tasksByStatus[col.id].length === 0 && (
|
||||||
<p className="text-xs text-[#d4d4d8] italic text-center py-10">
|
<p className="text-xs text-[#d4d4d8] italic text-center py-10">Nessun task</p>
|
||||||
Nessun task
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,18 +3,15 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { ClientKanban } from "./ClientKanban";
|
import { ClientKanban } from "./ClientKanban";
|
||||||
import type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
import type { Comment } from "@/db/schema";
|
|
||||||
|
|
||||||
export function PhaseViewToggle({
|
export function PhaseViewToggle({
|
||||||
timelineView,
|
timelineView,
|
||||||
phases,
|
phases,
|
||||||
token,
|
token,
|
||||||
comments,
|
|
||||||
}: {
|
}: {
|
||||||
timelineView: ReactNode;
|
timelineView: ReactNode;
|
||||||
phases: ClientView["phases"];
|
phases: ClientView["phases"];
|
||||||
token: string;
|
token: string;
|
||||||
comments: Comment[];
|
|
||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState<"timeline" | "kanban">("timeline");
|
const [view, setView] = useState<"timeline" | "kanban">("timeline");
|
||||||
|
|
||||||
@@ -26,9 +23,7 @@ export function PhaseViewToggle({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setView("timeline")}
|
onClick={() => setView("timeline")}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||||
view === "timeline"
|
view === "timeline" ? "bg-white text-[#1A463C] shadow-sm" : "text-[#71717a] hover:text-[#1a1a1a]"
|
||||||
? "bg-white text-[#1A463C] shadow-sm"
|
|
||||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Timeline
|
Timeline
|
||||||
@@ -36,9 +31,7 @@ export function PhaseViewToggle({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setView("kanban")}
|
onClick={() => setView("kanban")}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||||
view === "kanban"
|
view === "kanban" ? "bg-white text-[#1A463C] shadow-sm" : "text-[#71717a] hover:text-[#1a1a1a]"
|
||||||
? "bg-white text-[#1A463C] shadow-sm"
|
|
||||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Kanban
|
Kanban
|
||||||
@@ -46,11 +39,7 @@ export function PhaseViewToggle({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "timeline" ? (
|
{view === "timeline" ? timelineView : <ClientKanban phases={phases} token={token} />}
|
||||||
timelineView
|
|
||||||
) : (
|
|
||||||
<ClientKanban phases={phases} token={token} comments={comments} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,31 @@
|
|||||||
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 { 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;
|
token: string;
|
||||||
comments: Comment[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
|
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
|
||||||
if (status === 'done') {
|
if (status === 'done') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="w-5 h-5 text-[#16a34a]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
className="w-5 h-5 text-[#16a34a]"
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
className="w-5 h-5 text-[#0066cc]"
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
className="w-5 h-5 text-[#999999]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx={12} cy={12} r={9} />
|
<circle cx={12} cy={12} r={9} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -63,45 +34,20 @@ function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' })
|
|||||||
function TaskStatusIcon({ status }: { status: 'todo' | 'in_progress' | 'done' }) {
|
function TaskStatusIcon({ status }: { status: 'todo' | 'in_progress' | 'done' }) {
|
||||||
if (status === 'done') {
|
if (status === 'done') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="w-4 h-4 text-[#16a34a] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
className="w-4 h-4 text-[#16a34a] shrink-0 mt-0.5"
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === 'in_progress') {
|
if (status === 'in_progress') {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="w-4 h-4 text-[#DEF168] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
className="w-4 h-4 text-[#ca8a04] shrink-0 mt-0.5"
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
className="w-4 h-4 text-[#999999] shrink-0 mt-0.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx={12} cy={12} r={9} />
|
<circle cx={12} cy={12} r={9} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -115,23 +61,15 @@ const phaseStatusLabel: Record<'upcoming' | 'active' | 'done', string> = {
|
|||||||
|
|
||||||
const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
|
const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
|
||||||
upcoming: 'border-transparent bg-[#999999] text-white',
|
upcoming: 'border-transparent bg-[#999999] text-white',
|
||||||
active: 'border-transparent bg-[#0066cc] text-white',
|
active: 'border-transparent bg-[#1A463C] text-white',
|
||||||
done: 'border-transparent bg-[#16a34a] text-white',
|
done: 'border-transparent bg-[#16a34a] text-white',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PhaseTimeline({ phases, token, comments }: PhaseTimelineProps) {
|
export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
|
||||||
if (phases.length === 0) {
|
if (phases.length === 0) {
|
||||||
return (
|
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
|
||||||
<p className="text-sm text-[#999999] italic">
|
|
||||||
Nessuna fase ancora configurata.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) => {
|
||||||
@@ -140,110 +78,56 @@ export function PhaseTimeline({ phases, token, comments }: PhaseTimelineProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={phase.id} className="flex gap-5">
|
<div key={phase.id} className="flex gap-5">
|
||||||
{/* Colonna sinistra: indicatore timeline */}
|
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{/* Cerchio con icona stato */}
|
|
||||||
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
|
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
|
||||||
<PhaseStatusIcon status={phase.status} />
|
<PhaseStatusIcon status={phase.status} />
|
||||||
</div>
|
</div>
|
||||||
{/* Linea verticale verso la fase successiva */}
|
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
|
||||||
{!isLast && (
|
|
||||||
<div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Colonna destra: contenuto fase */}
|
|
||||||
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
|
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
|
||||||
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
|
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
|
||||||
{/* Header fase */}
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-4">
|
<div className="flex items-start justify-between gap-3 mb-4">
|
||||||
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">
|
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
|
||||||
{phase.title}
|
|
||||||
</h3>
|
|
||||||
<Badge className={`text-xs shrink-0 ${phaseStatusStyle[phase.status]}`}>
|
<Badge className={`text-xs shrink-0 ${phaseStatusStyle[phase.status]}`}>
|
||||||
{phaseStatusLabel[phase.status]}
|
{phaseStatusLabel[phase.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barra progresso fase (D-08) */}
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
<p className="text-xs text-[#666666] font-medium">
|
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
|
||||||
{doneCount} di {phase.tasks.length} task
|
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs font-semibold text-[#1a1a1a]">
|
|
||||||
{phase.progress_pct}%
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={phase.progress_pct} className="h-1.5" />
|
<Progress value={phase.progress_pct} className="h-1.5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lista task */}
|
|
||||||
{phase.tasks.length === 0 ? (
|
{phase.tasks.length === 0 ? (
|
||||||
<p className="text-xs text-[#999999] italic">
|
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
|
||||||
Nessun task ancora configurato.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-3">
|
||||||
{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} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p
|
<p className={`text-sm leading-snug ${task.status === 'done' ? 'line-through text-[#999999]' : 'text-[#1a1a1a]'}`}>
|
||||||
className={`text-sm leading-snug ${
|
|
||||||
task.status === 'done'
|
|
||||||
? 'line-through text-[#999999]'
|
|
||||||
: 'text-[#1a1a1a]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-xs text-[#999999] mt-0.5 leading-snug">
|
<p className="text-xs text-[#999999] mt-0.5 leading-snug">{task.description}</p>
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Deliverable annidati con ApproveButton + CommentiDeliverable */}
|
|
||||||
{task.deliverables.length > 0 && (
|
{task.deliverables.length > 0 && (
|
||||||
<ul className="mt-1.5 space-y-3">
|
<ul className="mt-1.5 space-y-2">
|
||||||
{task.deliverables.map((d) => (
|
{task.deliverables.map((d) => (
|
||||||
<li
|
<li key={d.id} className="bg-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2">
|
||||||
key={d.id}
|
<span className="text-xs text-[#666666] truncate font-medium">{d.title}</span>
|
||||||
className="bg-[#f9f9f9] rounded px-3 py-2"
|
|
||||||
>
|
|
||||||
<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) && (
|
{(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && (
|
||||||
<ApproveButton
|
<ApproveButton deliverableId={d.id} token={token} approvedAt={d.approved_at} />
|
||||||
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