feat: offer badges, unified dashboard, income forecast, Pipedrive-style follow-up
- Client detail: category + tier badges on active offers - Dashboard: remove top KPI cards + recent activity; fold current KPIs into bottom MetricCard style; add 12-month income forecast chart - Forecast query: branch by offer_type (retainer = monthly fee, una_tantum = spread over duration), filter archived projects - Payments: set the month a payment was collected (paid_at) from PaymentsTab - Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
updatePaymentStatus,
|
||||
updateAcceptedTotal,
|
||||
setPaymentPaidAt,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +28,16 @@ const statusLabels: Record<string, string> = {
|
||||
saldato: "Saldato",
|
||||
};
|
||||
|
||||
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
||||
function toMonthValue(paidAt: Date | string | null | undefined): string {
|
||||
if (!paidAt) {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
const d = paidAt instanceof Date ? paidAt : new Date(paidAt);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
type PlanMode = "single" | "two" | "three";
|
||||
|
||||
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
||||
@@ -90,6 +101,17 @@ export function PaymentsTab({
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) {
|
||||
if (!monthStr) return;
|
||||
setStatusLoading(paymentId);
|
||||
try {
|
||||
await setPaymentPaidAt(paymentId, clientId, monthStr);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setStatusLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-md">
|
||||
{/* Totale preventivo */}
|
||||
@@ -240,6 +262,21 @@ export function PaymentsTab({
|
||||
<span className="text-xs text-[#71717a]">...</span>
|
||||
)}
|
||||
</div>
|
||||
{p.status === "saldato" && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
||||
Incassato nel mese
|
||||
</Label>
|
||||
<input
|
||||
id={`paid-${p.id}`}
|
||||
type="month"
|
||||
defaultValue={toMonthValue(p.paid_at)}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={(e) => handlePaidMonthUpdate(p.id, e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user