feat: sidebar CTA removal, delete phase/task, public dashboard UX, chat panel
1. AdminSidebar: removed yellow "Genera preventivo" CTA (duplicated Preventivi tab nav item) 2. Delete phase/task: new deletePhase/deleteTask server actions with FK cascade and phase-status recompute; DeletePhaseTaskButton client component with window.confirm; trash icons in PhasesTab per phase and per task 3. Public dashboard: extend activeOffers query with offer_macros join (offer_name, offer_type); reorder sidebar 1°Offerte 2°Pagamenti 3°Documenti; OffersSection shows macro public_name as heading (never tier letter/internal_name); PaymentStatus gains totalLabel/overrideAmount/hideRows props — retainer=monthly label + no Acconto/Saldo rows 4. Offer editor: Descrizione textarea (nota interna) and Modalità toggle moved directly under title; Tags section now contains only Categoria/Ticket/Tipo/Obiettivo 5. Basecamp chat: API route supports entity_type="phase" with authorization scoping; comments scope in client-view broadened to include phaseIds and client_id (general); CommentsTab updated with phase/general entities; ChatProvider React context; ChatPanel fixed slide-in panel with FAB (bottom-right) and composer tag selector (Generale + phases); PhaseCard gets chat-bubble icon pre-tagging that phase; inline ChatSection block removed from dashboard main column Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,17 +39,6 @@ export function AdminSidebar() {
|
||||
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
{/* CTA globale — genera preventivo */}
|
||||
<div className="px-3 py-3 border-b border-white/10">
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-md text-xs font-semibold bg-[#DEF168] text-[#1A463C] hover:bg-[#d4e85e] transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
Genera preventivo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deletePhase, deleteTask } from "@/app/admin/clients/[id]/actions";
|
||||
|
||||
interface DeletePhaseButtonProps {
|
||||
type: "phase";
|
||||
phaseId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
interface DeleteTaskButtonProps {
|
||||
type: "task";
|
||||
taskId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
type Props = DeletePhaseButtonProps | DeleteTaskButtonProps;
|
||||
|
||||
export function DeletePhaseTaskButton(props: Props) {
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function handleClick() {
|
||||
const label = props.type === "phase" ? "questa fase (e tutti i task/deliverable al suo interno)" : "questo task";
|
||||
if (!window.confirm(`Eliminare ${label}? L'azione non è reversibile.`)) return;
|
||||
|
||||
startTransition(async () => {
|
||||
if (props.type === "phase") {
|
||||
await deletePhase(props.phaseId, props.clientId);
|
||||
} else {
|
||||
await deleteTask(props.taskId, props.clientId);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={props.type === "phase" ? "Elimina fase" : "Elimina task"}
|
||||
className="p-1 rounded text-[#999999] hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
aria-label={props.type === "phase" ? "Elimina fase" : "Elimina task"}
|
||||
>
|
||||
{/* Trash icon */}
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -296,6 +296,42 @@ export function OfferEditorClient({
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Modalità toggle — directly under title */}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
|
||||
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
|
||||
{([
|
||||
{ value: "una_tantum", label: "Una tantum" },
|
||||
{ value: "retainer", label: "Retainer" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => updateMacro("offer_type", opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
macro.offer_type === opt.value
|
||||
? "bg-[#1A463C] text-white font-medium"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descrizione — nota interna, non visibile al cliente */}
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0 pt-2">Descrizione</span>
|
||||
<textarea
|
||||
value={macro.description}
|
||||
onChange={(e) => updateMacro("description", e.target.value)}
|
||||
placeholder="Descrizione interna dell'offerta (non visibile al cliente)..."
|
||||
rows={3}
|
||||
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 text-[#1a1a1a] placeholder:text-[#aaaaaa]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags block */}
|
||||
@@ -324,29 +360,6 @@ export function OfferEditorClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
|
||||
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
|
||||
{([
|
||||
{ value: "una_tantum", label: "Una tantum" },
|
||||
{ value: "retainer", label: "Retainer" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => updateMacro("offer_type", opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
macro.offer_type === opt.value
|
||||
? "bg-[#1A463C] text-white font-medium"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
|
||||
<OptionMultiSelect
|
||||
|
||||
@@ -11,9 +11,12 @@ type Props = {
|
||||
};
|
||||
|
||||
export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
// Build entity label map for display
|
||||
const entityLabels: Record<string, string> = {};
|
||||
// Build entity label map for display (phases, tasks, deliverables, and general)
|
||||
const entityLabels: Record<string, string> = {
|
||||
[clientId]: "Messaggio generale",
|
||||
};
|
||||
for (const phase of phases) {
|
||||
entityLabels[phase.id] = `Fase: ${phase.title}`;
|
||||
for (const task of phase.tasks) {
|
||||
entityLabels[task.id] = `Task: ${task.title}`;
|
||||
for (const d of task.deliverables) {
|
||||
@@ -23,8 +26,11 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
}
|
||||
|
||||
// Build list of entities the admin can reply on
|
||||
const entities: Array<{ id: string; type: string; label: string }> = [];
|
||||
const entities: Array<{ id: string; type: string; label: string }> = [
|
||||
{ id: clientId, type: "general", label: "Messaggio generale" },
|
||||
];
|
||||
for (const phase of phases) {
|
||||
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
|
||||
for (const task of phase.tasks) {
|
||||
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
||||
for (const d of task.deliverables) {
|
||||
@@ -67,39 +73,37 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Admin reply form */}
|
||||
{entities.length > 0 && (
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await postAdminComment(clientId, fd);
|
||||
}}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await postAdminComment(clientId, fd);
|
||||
}}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 text-sm">
|
||||
Rispondi come admin
|
||||
</h3>
|
||||
<select
|
||||
name="entity"
|
||||
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
|
||||
required
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 text-sm">
|
||||
Rispondi come admin
|
||||
</h3>
|
||||
<select
|
||||
name="entity"
|
||||
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
|
||||
required
|
||||
>
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={`${e.type}:${e.id}`}>
|
||||
{e.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
name="body"
|
||||
placeholder="Scrivi un commento..."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Invia risposta
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={`${e.type}:${e.id}`}>
|
||||
{e.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
name="body"
|
||||
placeholder="Scrivi un commento..."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Invia risposta
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
@@ -58,32 +59,35 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updatePhaseStatus(
|
||||
phase.id,
|
||||
clientId,
|
||||
fd.get("status") as string
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={phase.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
<div className="flex items-center gap-2">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updatePhaseStatus(
|
||||
phase.id,
|
||||
clientId,
|
||||
fd.get("status") as string
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{phaseStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={phase.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{phaseStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
@@ -94,37 +98,40 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-800">{task.title}</span>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateTaskStatus(
|
||||
task.id,
|
||||
clientId,
|
||||
fd.get("status") as string
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={task.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
<div className="flex items-center gap-1">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateTaskStatus(
|
||||
task.id,
|
||||
clientId,
|
||||
fd.get("status") as string
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{taskStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs px-1"
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
</form>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={task.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{taskStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs px-1"
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
</form>
|
||||
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user