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:
@@ -117,6 +117,23 @@ export async function addDocument(clientId: string, formData: FormData) {
|
|||||||
revalidatePath(`/admin/clients/${clientId}`);
|
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) {
|
export async function deleteDocument(documentId: string, clientId: string) {
|
||||||
await db.delete(documents).where(eq(documents.id, documentId));
|
await db.delete(documents).where(eq(documents.id, documentId));
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -14,9 +15,9 @@ export async function DocumentsTab({ documents, clientId }: Props) {
|
|||||||
"use server";
|
"use server";
|
||||||
await addDocument(clientId, fd);
|
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">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="doc-label">Nome / etichetta</Label>
|
<Label htmlFor="doc-label">Nome / etichetta</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -42,38 +43,11 @@ export async function DocumentsTab({ documents, clientId }: Props) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{documents.length === 0 && (
|
{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">
|
<div className="space-y-2">
|
||||||
{documents.map((doc) => (
|
{documents.map((doc) => (
|
||||||
<div
|
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,21 +16,16 @@ interface ClientDashboardProps {
|
|||||||
export function ClientDashboard({ view, token, comments }: 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 */}
|
||||||
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
|
<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">
|
<div className="flex items-center">
|
||||||
{/* iamcavalli — piccolo, angolo sinistro */}
|
|
||||||
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
|
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
|
||||||
iamcavalli
|
iamcavalli
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Brand name cliente — prominente, centrato */}
|
|
||||||
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
|
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
|
||||||
{view.client.brand_name}
|
{view.client.brand_name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Spacer bilanciatore */}
|
|
||||||
<div className="w-28 shrink-0" />
|
<div className="w-28 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +33,7 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
|||||||
|
|
||||||
{/* Barra progresso globale */}
|
{/* Barra progresso globale */}
|
||||||
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
|
<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">
|
<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-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
|
||||||
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contenuto principale */}
|
{/* Layout principale: sidebar sinistra + contenuto destro */}
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 py-10 space-y-14">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
{/* Brief progetto */}
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fasi — toggle timeline/kanban */}
|
{/* ── Sidebar sinistra ── */}
|
||||||
<section>
|
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
|
||||||
<PhaseViewToggle
|
<div className="lg:sticky lg:top-[89px] space-y-6">
|
||||||
timelineView={<PhaseTimeline phases={view.phases} token={token} comments={comments} />}
|
|
||||||
phases={view.phases}
|
|
||||||
token={token}
|
|
||||||
comments={comments}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Stato pagamenti — sempre visibile (D-10) */}
|
{/* Pagamenti */}
|
||||||
<section>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Pagamenti</h2>
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
||||||
|
Pagamenti
|
||||||
|
</h2>
|
||||||
<PaymentStatus
|
<PaymentStatus
|
||||||
accepted_total={view.client.accepted_total}
|
accepted_total={view.client.accepted_total}
|
||||||
payments={view.payments}
|
payments={view.payments}
|
||||||
/>
|
/>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{/* Documenti — solo se presenti */}
|
{/* Documenti — sempre mostrati in sidebar (anche vuoti) */}
|
||||||
{view.documents.length > 0 && (
|
<div>
|
||||||
<section>
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
||||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Documenti & File</h2>
|
Documenti & File
|
||||||
|
</h2>
|
||||||
<DocumentsSection documents={view.documents} />
|
<DocumentsSection documents={view.documents} />
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Note / Log decisioni — solo se presenti */}
|
{/* Fasi — toggle timeline/kanban */}
|
||||||
{view.notes.length > 0 && (
|
<PhaseViewToggle
|
||||||
<section>
|
timelineView={
|
||||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Note & Decisioni</h2>
|
<PhaseTimeline phases={view.phases} token={token} comments={comments} />
|
||||||
<NotesSection notes={view.notes} />
|
}
|
||||||
</section>
|
phases={view.phases}
|
||||||
)}
|
token={token}
|
||||||
|
comments={comments}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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-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">
|
<p className="text-xs text-[#999999] text-center">
|
||||||
Questa e' la tua dashboard privata — non condividere il link.
|
Questa è la tua dashboard privata — non condividere il link.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
Reference in New Issue
Block a user