feat: document edit inline + client dashboard sidebar layout

- actions.ts: add updateDocument server action (label + url, Zod validated)
- DocumentRow: Client Component with hover-reveal edit/remove buttons,
  inline edit form with pre-filled fields and cancel/save
- DocumentsTab: use DocumentRow, remove variant dependency
- client-dashboard: two-column layout (sidebar left on lg+):
  sidebar = payments + documents + notes (sticky top)
  main = brief + phases toggle (timeline / kanban)
  mobile: main first, sidebar below (order-1/order-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simone Cavalli
2026-05-16 12:24:49 +02:00
parent 7af917fe80
commit 3582e26970
4 changed files with 199 additions and 84 deletions
+17
View File
@@ -117,6 +117,23 @@ export async function addDocument(clientId: string, formData: FormData) {
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateDocument(
documentId: string,
clientId: string,
formData: FormData
) {
const parsed = docSchema.safeParse({
label: formData.get("label"),
url: formData.get("url"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db
.update(documents)
.set(parsed.data)
.where(eq(documents.id, documentId));
revalidatePath(`/admin/clients/${clientId}`);
}
export async function deleteDocument(documentId: string, clientId: string) {
await db.delete(documents).where(eq(documents.id, documentId));
revalidatePath(`/admin/clients/${clientId}`);
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { updateDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
import type { Document } from "@/db/schema";
export function DocumentRow({
doc,
clientId,
}: {
doc: Document;
clientId: string;
}) {
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
function handleSave(fd: FormData) {
setError(null);
startTransition(async () => {
try {
await updateDocument(doc.id, clientId, fd);
setEditing(false);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
function handleDelete() {
startTransition(async () => {
await deleteDocument(doc.id, clientId);
router.refresh();
});
}
if (editing) {
return (
<form
action={handleSave}
className="bg-white border-2 border-[#1A463C]/30 rounded-lg px-4 py-3 space-y-2"
>
<Input
name="label"
defaultValue={doc.label}
placeholder="Nome / etichetta"
required
className="text-sm"
/>
<Input
name="url"
defaultValue={doc.url}
type="url"
placeholder="https://..."
required
className="text-sm"
/>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm">
Salva
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setEditing(false); setError(null); }}
className="text-[#71717a]"
>
Annulla
</Button>
</div>
</form>
);
}
return (
<div className="flex items-center justify-between bg-white border border-[#e5e7eb] rounded-lg px-4 py-3 group">
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#1A463C] hover:underline font-medium truncate min-w-0 mr-3"
>
{doc.label}
</a>
<div className="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
className="text-xs text-[#71717a] hover:text-[#1A463C] opacity-0 group-hover:opacity-100 transition-opacity"
>
Modifica
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDelete}
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
>
Rimuovi
</Button>
</div>
</div>
);
}
+6 -32
View File
@@ -1,4 +1,5 @@
import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
import { addDocument } from "@/app/admin/clients/[id]/actions";
import { DocumentRow } from "@/components/admin/DocumentRow";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -14,9 +15,9 @@ export async function DocumentsTab({ documents, clientId }: Props) {
"use server";
await addDocument(clientId, fd);
}}
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
className="bg-white border border-[#e5e7eb] rounded-lg p-4 space-y-3"
>
<h3 className="font-medium text-gray-900">Aggiungi documento</h3>
<h3 className="font-medium text-[#1a1a1a]">Aggiungi documento</h3>
<div className="space-y-1">
<Label htmlFor="doc-label">Nome / etichetta</Label>
<Input
@@ -42,38 +43,11 @@ export async function DocumentsTab({ documents, clientId }: Props) {
</form>
{documents.length === 0 && (
<p className="text-sm text-gray-400">Nessun documento ancora.</p>
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
)}
<div className="space-y-2">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3"
>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
{doc.label}
</a>
<form
action={async () => {
"use server";
await deleteDocument(doc.id, clientId);
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 text-xs"
>
Rimuovi
</Button>
</form>
</div>
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
))}
</div>
</div>
+62 -52
View File
@@ -16,21 +16,16 @@ interface ClientDashboardProps {
export function ClientDashboard({ view, token, comments }: ClientDashboardProps) {
return (
<div className="min-h-screen bg-white">
{/* Header: logo iamcavalli (piccolo) + brand_name cliente (prominente) */}
{/* Header */}
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-5">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<div className="flex items-center">
{/* iamcavalli — piccolo, angolo sinistro */}
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
iamcavalli
</span>
{/* Brand name cliente — prominente, centrato */}
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
{view.client.brand_name}
</h1>
{/* Spacer bilanciatore */}
<div className="w-28 shrink-0" />
</div>
</div>
@@ -38,7 +33,7 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
{/* Barra progresso globale */}
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-5">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
@@ -47,58 +42,73 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
</div>
</section>
{/* Contenuto principale */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 py-10 space-y-14">
{/* Brief progetto */}
{view.client.brief && (
<section>
<p className="text-base text-[#666666] italic leading-relaxed border-l-4 border-[#0066cc] pl-4">
{view.client.brief}
</p>
</section>
)}
{/* Layout principale: sidebar sinistra + contenuto destro */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Fasi — toggle timeline/kanban */}
<section>
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} comments={comments} />}
phases={view.phases}
token={token}
comments={comments}
/>
</section>
{/* ── Sidebar sinistra ── */}
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
<div className="lg:sticky lg:top-[89px] space-y-6">
{/* Stato pagamenti — sempre visibile (D-10) */}
<section>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Pagamenti</h2>
<PaymentStatus
accepted_total={view.client.accepted_total}
payments={view.payments}
/>
</section>
{/* Pagamenti */}
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
Pagamenti
</h2>
<PaymentStatus
accepted_total={view.client.accepted_total}
payments={view.payments}
/>
</div>
{/* Documenti — solo se presenti */}
{view.documents.length > 0 && (
<section>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Documenti & File</h2>
<DocumentsSection documents={view.documents} />
</section>
)}
{/* Documenti — sempre mostrati in sidebar (anche vuoti) */}
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
Documenti & File
</h2>
<DocumentsSection documents={view.documents} />
</div>
{/* Note / Log decisioni — solo se presenti */}
{view.notes.length > 0 && (
<section>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Note & Decisioni</h2>
<NotesSection notes={view.notes} />
</section>
)}
</main>
{/* Note — solo se presenti */}
{view.notes.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
Note & Decisioni
</h2>
<NotesSection notes={view.notes} />
</div>
)}
</div>
</aside>
{/* ── Contenuto principale ── */}
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-10">
{/* Brief progetto */}
{view.client.brief && (
<p className="text-base text-[#666666] italic leading-relaxed border-l-4 border-[#DEF168] pl-4">
{view.client.brief}
</p>
)}
{/* Fasi — toggle timeline/kanban */}
<PhaseViewToggle
timelineView={
<PhaseTimeline phases={view.phases} token={token} comments={comments} />
}
phases={view.phases}
token={token}
comments={comments}
/>
</main>
</div>
</div>
{/* Footer */}
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<div className="max-w-4xl 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">
Questa e&apos; la tua dashboard privata non condividere il link.
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
</footer>