feat(04-03): project workspace + timer analytics + settings page

- /admin/projects/[id]: full workspace with 7 tabs (Fasi, Pagamenti,
  Documenti, Note, Commenti, Preventivo, Timer)
- TimerTab: TimerCell + ProfitabilityCard with €/h real, ideal cost, delta
- ProfitabilityCard: hours worked, €/h real vs target, profit/loss delta
- /admin/impostazioni: target_hourly_rate setting (default 50€/h)
- actions.ts: resolveEntity() helper — actions now work transparently
  with both clientId and projectId (same tab components reused)
- quote-actions.ts: same resolveEntity pattern for addQuoteItem,
  removeQuoteItem, updateAcceptedTotal
- timer-actions.ts: revalidate /admin/projects and /admin/projects/[id]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:05:31 +02:00
parent ef05d647fe
commit 1d9fa10961
7 changed files with 442 additions and 108 deletions
@@ -0,0 +1,75 @@
type ProfitabilityCardProps = {
acceptedTotal: string;
totalTrackedSeconds: number;
targetHourlyRate: number;
};
export function ProfitabilityCard({
acceptedTotal,
totalTrackedSeconds,
targetHourlyRate,
}: ProfitabilityCardProps) {
const hours = totalTrackedSeconds / 3600;
const accepted = parseFloat(acceptedTotal || "0");
const realHourlyRate = hours > 0 ? accepted / hours : null;
const idealCost = targetHourlyRate * hours;
const delta = accepted - idealCost;
const deltaIsProfit = delta >= 0;
return (
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3">
<h3 className="font-medium text-[#1a1a1a]">Profittabilità</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-[#71717a] text-xs">Ore lavorate</p>
<p className="font-mono font-semibold text-[#1a1a1a]">{hours.toFixed(1)}h</p>
</div>
<div>
<p className="text-[#71717a] text-xs">Importo accettato</p>
<p className="font-mono font-semibold text-[#1a1a1a]">
{accepted > 0
? `${accepted.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`
: "Non impostato"}
</p>
</div>
</div>
<div className="border-t border-[#f4f4f5] pt-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[#71717a]">/h reale</span>
<span className="font-mono font-semibold text-[#1a1a1a]">
{realHourlyRate !== null ? `${realHourlyRate.toFixed(2)}/h` : "—"}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#71717a]">/h target</span>
<span className="font-mono font-semibold text-[#71717a]">
{targetHourlyRate.toFixed(2)}/h
</span>
</div>
<div className="flex justify-between">
<span className="text-[#71717a]">
Costo ideale ({hours.toFixed(1)}h × {targetHourlyRate}/h)
</span>
<span className="font-mono font-semibold text-[#1a1a1a]">{idealCost.toFixed(2)}</span>
</div>
</div>
{hours > 0 && accepted > 0 && (
<div className="border-t border-[#f4f4f5] pt-3 flex justify-between items-center">
<span className="text-[#71717a]">Delta (guadagno/perdita)</span>
<span className={`font-mono font-bold ${deltaIsProfit ? "text-green-600" : "text-red-600"}`}>
{deltaIsProfit ? "+" : ""}{delta.toFixed(2)}
</span>
</div>
)}
{hours === 0 && (
<p className="text-xs text-[#71717a] border-t border-[#f4f4f5] pt-3">
Avvia il timer per iniziare a tracciare le ore.
</p>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { TimerCell } from "@/components/admin/TimerCell";
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
type TimerTabProps = {
projectId: string;
acceptedTotal: string;
activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
targetHourlyRate: number;
};
export function TimerTab({
projectId,
acceptedTotal,
activeTimerEntryId,
activeTimerStartedAt,
totalTrackedSeconds,
targetHourlyRate,
}: TimerTabProps) {
return (
<div className="space-y-6 max-w-sm">
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
<TimerCell
clientId={projectId}
activeEntryId={activeTimerEntryId}
activeStartedAt={activeTimerStartedAt}
totalTrackedSeconds={totalTrackedSeconds}
/>
</div>
<ProfitabilityCard
acceptedTotal={acceptedTotal}
totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate}
/>
</div>
);
}