Compare commits
3 Commits
571f58bff8
...
d6e95ef66a
| Author | SHA1 | Date | |
|---|---|---|---|
| d6e95ef66a | |||
| a9358da96f | |||
| 4b135ce67f |
@@ -18,7 +18,6 @@ import {
|
|||||||
payments,
|
payments,
|
||||||
clients,
|
clients,
|
||||||
projects,
|
projects,
|
||||||
comments,
|
|
||||||
client_emails,
|
client_emails,
|
||||||
otp_codes,
|
otp_codes,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
@@ -368,21 +367,11 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
|
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||||
|
// La risposta dell'admin vive in `replyToConversation`
|
||||||
export async function postAdminComment(id: string, formData: FormData) {
|
// (src/app/admin/conversazioni/actions.ts): unica inbox, un solo punto di
|
||||||
await requireAdmin();
|
// scrittura. Qui c'era `postAdminComment`, che rispondeva sulla singola entità
|
||||||
const entity = formData.get("entity") as string;
|
// dal tab Commenti del progetto — tab rimosso, action con lui.
|
||||||
const body = (formData.get("body") as string)?.trim();
|
|
||||||
if (!body || !entity) throw new Error("Dati mancanti");
|
|
||||||
const [entity_type, entity_id] = entity.split(":");
|
|
||||||
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
|
||||||
const allowedTypes = ["task", "deliverable", "phase", "general"];
|
|
||||||
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
|
||||||
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
|
||||||
const { path } = await resolveEntity(id);
|
|
||||||
revalidatePath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ACCESSI PORTALE (whitelist OTP) ──────────────────────────────────────────
|
// ── ACCESSI PORTALE (whitelist OTP) ──────────────────────────────────────────
|
||||||
// La whitelist è l'unico modo per entrare nel portale: nessuna auto-registrazione.
|
// La whitelist è l'unico modo per entrare nel portale: nessuna auto-registrazione.
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ async function requireAdmin() {
|
|||||||
/**
|
/**
|
||||||
* Admin reply from the Conversazioni inbox. Per project decision, replies are
|
* Admin reply from the Conversazioni inbox. Per project decision, replies are
|
||||||
* saved as a "general" comment on the client (entity_id = clientId), so they
|
* saved as a "general" comment on the client (entity_id = clientId), so they
|
||||||
* surface in the client's general chat and in the client detail CommentsTab.
|
* surface in the client's general chat.
|
||||||
|
*
|
||||||
|
* Questa è l'UNICA via di risposta dell'admin da quando il tab Commenti del
|
||||||
|
* progetto è stato rimosso: i messaggi su fase/task/deliverable si leggono qui
|
||||||
|
* con la loro etichetta, ma la risposta torna sempre sul thread generale.
|
||||||
*/
|
*/
|
||||||
export async function replyToConversation(clientId: string, formData: FormData) {
|
export async function replyToConversation(clientId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|||||||
+1
-45
@@ -12,54 +12,10 @@ import { YearSelector } from "@/components/admin/YearSelector";
|
|||||||
import { ForecastChart } from "@/components/admin/ForecastChart";
|
import { ForecastChart } from "@/components/admin/ForecastChart";
|
||||||
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||||
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
||||||
|
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
function MetricCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueAccent,
|
|
||||||
delta,
|
|
||||||
sub,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
valueAccent?: boolean;
|
|
||||||
delta?: string;
|
|
||||||
sub?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
|
|
||||||
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-baseline gap-2 mt-1">
|
|
||||||
<span
|
|
||||||
className={`text-2xl font-bold font-mono ${
|
|
||||||
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{delta ? (
|
|
||||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
|
|
||||||
) : sub ? (
|
|
||||||
<span className="text-xs text-muted-foreground">{sub}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtEur0(n: number) {
|
|
||||||
return n.toLocaleString("it-IT", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "EUR",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminDashboard({
|
export default async function AdminDashboard({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
|
||||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
|
import { ProjectSummary } from "@/components/admin/ProjectSummary";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@@ -34,10 +34,13 @@ export default async function ProjectDetailPage({
|
|||||||
payments,
|
payments,
|
||||||
documents,
|
documents,
|
||||||
notes,
|
notes,
|
||||||
comments,
|
|
||||||
activeTimerEntryId,
|
activeTimerEntryId,
|
||||||
activeTimerStartedAt,
|
activeTimerStartedAt,
|
||||||
|
activeTimerPhaseId,
|
||||||
|
activeTimerTaskId,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
|
taskSeconds,
|
||||||
|
phaseSeconds,
|
||||||
projectOffers,
|
projectOffers,
|
||||||
availableMicros,
|
availableMicros,
|
||||||
offersAcceptedTotal,
|
offersAcceptedTotal,
|
||||||
@@ -66,20 +69,38 @@ export default async function ProjectDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProjectSummary
|
||||||
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
|
payments={payments}
|
||||||
|
phases={phases}
|
||||||
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
targetHourlyRate={targetHourlyRate}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="phases" className="w-full">
|
<Tabs defaultValue="phases" className="w-full">
|
||||||
<TabsList className="mb-6">
|
<TabsList className="mb-6">
|
||||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
|
||||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="phases">
|
<TabsContent value="phases">
|
||||||
<PhasesViewToggle
|
<PhasesViewToggle
|
||||||
listView={<PhasesTab phases={phases} clientId={id} />}
|
listView={
|
||||||
|
<PhasesTab
|
||||||
|
phases={phases}
|
||||||
|
clientId={id}
|
||||||
|
projectId={id}
|
||||||
|
activeTimerEntryId={activeTimerEntryId}
|
||||||
|
activeTimerStartedAt={activeTimerStartedAt}
|
||||||
|
activeTimerTaskId={activeTimerTaskId}
|
||||||
|
taskSeconds={taskSeconds}
|
||||||
|
phaseSeconds={phaseSeconds}
|
||||||
|
/>
|
||||||
|
}
|
||||||
phases={phases}
|
phases={phases}
|
||||||
clientId={id}
|
clientId={id}
|
||||||
/>
|
/>
|
||||||
@@ -115,16 +136,13 @@ export default async function ProjectDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comments">
|
|
||||||
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="timer">
|
<TabsContent value="timer">
|
||||||
<TimerTab
|
<TimerTab
|
||||||
projectId={id}
|
projectId={id}
|
||||||
acceptedTotal={project.accepted_total ?? "0"}
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
activeTimerEntryId={activeTimerEntryId}
|
activeTimerEntryId={activeTimerEntryId}
|
||||||
activeTimerStartedAt={activeTimerStartedAt}
|
activeTimerStartedAt={activeTimerStartedAt}
|
||||||
|
activeTimerScoped={activeTimerPhaseId !== null || activeTimerTaskId !== null}
|
||||||
totalTrackedSeconds={totalTrackedSeconds}
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
targetHourlyRate={targetHourlyRate}
|
targetHourlyRate={targetHourlyRate}
|
||||||
recentEntries={recentEntries}
|
recentEntries={recentEntries}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export default async function ProjectsPage() {
|
|||||||
<th className="py-4 px-6 text-right">Valore Totale</th>
|
<th className="py-4 px-6 text-right">Valore Totale</th>
|
||||||
<th className="py-4 px-6 text-center">Acconto</th>
|
<th className="py-4 px-6 text-center">Acconto</th>
|
||||||
<th className="py-4 px-6 text-center">Saldo</th>
|
<th className="py-4 px-6 text-center">Saldo</th>
|
||||||
<th className="py-4 px-6 text-center">Timer</th>
|
|
||||||
<th className="py-4 px-6 text-right">Redditività (€/H)</th>
|
<th className="py-4 px-6 text-right">Redditività (€/H)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -9,11 +9,26 @@ async function requireAdmin() {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) throw new Error("Non autorizzato");
|
if (!session) throw new Error("Non autorizzato");
|
||||||
}
|
}
|
||||||
import { time_entries, projects } from "@/db/schema";
|
import { time_entries } from "@/db/schema";
|
||||||
import { eq, isNull, asc } from "drizzle-orm";
|
import { eq, isNull } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
/**
|
||||||
|
* Avvia il timer su un progetto e, facoltativamente, sulla fase e sul task su
|
||||||
|
* cui si sta lavorando.
|
||||||
|
*
|
||||||
|
* Quando si cronometra un task si valorizzano ENTRAMBI `phaseId` e `taskId`:
|
||||||
|
* il totale di una fase diventa così un semplice raggruppamento su `phase_id`,
|
||||||
|
* senza dover risalire dai task. Il tempo imputato alla fase ma a nessun task
|
||||||
|
* (phaseId senza taskId) resta possibile ed entra nello stesso totale.
|
||||||
|
*
|
||||||
|
* Resta valida la regola di prima: un solo timer attivo alla volta, in tutto
|
||||||
|
* l'hub. Vale globalmente, non per task — non si lavora su due cose insieme.
|
||||||
|
*/
|
||||||
|
export async function startTimer(
|
||||||
|
projectId: string,
|
||||||
|
scope?: { phaseId?: string; taskId?: string }
|
||||||
|
): Promise<{ entryId: string }> {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
// Stop any currently running session before starting a new one
|
// Stop any currently running session before starting a new one
|
||||||
const running = await db
|
const running = await db
|
||||||
@@ -38,26 +53,18 @@ export async function startTimer(projectId: string): Promise<{ entryId: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
await db.insert(time_entries).values({ id, project_id: projectId });
|
await db.insert(time_entries).values({
|
||||||
|
id,
|
||||||
|
project_id: projectId,
|
||||||
|
phase_id: scope?.phaseId ?? null,
|
||||||
|
task_id: scope?.taskId ?? null,
|
||||||
|
});
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
revalidatePath("/admin/projects");
|
revalidatePath("/admin/projects");
|
||||||
revalidatePath(`/admin/projects/${projectId}`);
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
return { entryId: id };
|
return { entryId: id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> {
|
|
||||||
await requireAdmin();
|
|
||||||
const projectRows = await db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(eq(projects.client_id, clientId))
|
|
||||||
.orderBy(asc(projects.created_at))
|
|
||||||
.limit(1);
|
|
||||||
const projectId = projectRows[0]?.id;
|
|
||||||
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
|
||||||
return startTimer(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopTimer(entryId: string): Promise<void> {
|
export async function stopTimer(entryId: string): Promise<void> {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Riquadro di una metrica. Estratto dalla dashboard admin quando è servito anche
|
||||||
|
* al riepilogo del singolo progetto: due copie della stessa card avrebbero
|
||||||
|
* iniziato a divergere alla prima modifica.
|
||||||
|
*
|
||||||
|
* `delta` e `sub` occupano lo stesso posto: `delta` vince quando c'è, perché una
|
||||||
|
* variazione è più interessante di una didascalia.
|
||||||
|
*/
|
||||||
|
export function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueAccent,
|
||||||
|
delta,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueAccent?: boolean;
|
||||||
|
delta?: string;
|
||||||
|
sub?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-baseline gap-2 mt-1">
|
||||||
|
<span
|
||||||
|
className={`text-2xl font-bold font-mono ${
|
||||||
|
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{delta ? (
|
||||||
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
|
||||||
|
) : sub ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{sub}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatta in euro senza decimali — per i totali, dove i centesimi sono rumore. */
|
||||||
|
export function fmtEur0(n: number) {
|
||||||
|
return n.toLocaleString("it-IT", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TimerCell } from "@/components/admin/TimerCell";
|
|
||||||
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
@@ -80,18 +79,6 @@ export function ProjectRow({ project }: { project: ProjectWithPayments }) {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-4 px-6">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<TimerCell
|
|
||||||
clientId={project.id}
|
|
||||||
projectId={project.id}
|
|
||||||
activeEntryId={project.activeTimerEntryId}
|
|
||||||
activeStartedAt={project.activeTimerStartedAt}
|
|
||||||
totalTrackedSeconds={project.totalTrackedSeconds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-4 px-6 text-right whitespace-nowrap">
|
<td className="py-4 px-6 text-right whitespace-nowrap">
|
||||||
{eurPerHour === null ? (
|
{eurPerHour === null ? (
|
||||||
<span className="text-muted-foreground/40 font-medium">—</span>
|
<span className="text-muted-foreground/40 font-medium">—</span>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
|
||||||
|
import type { ProjectFullDetail } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
acceptedTotal: string;
|
||||||
|
payments: ProjectFullDetail["payments"];
|
||||||
|
phases: ProjectFullDetail["phases"];
|
||||||
|
totalTrackedSeconds: number;
|
||||||
|
targetHourlyRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riepilogo in testa al progetto: i soldi e l'avanzamento, prima dei tab.
|
||||||
|
*
|
||||||
|
* Nessuna query nuova — sono tutti dati che `getProjectFullDetail` restituisce
|
||||||
|
* già per i tab sottostanti. È aritmetica su quello che c'è.
|
||||||
|
*
|
||||||
|
* L'incassato si legge dai `payments`, non da `accepted_total`: il contratto
|
||||||
|
* dice quanto vale il progetto, le rate dicono quanto è entrato davvero, e
|
||||||
|
* quando i due non tornano è un'informazione, non un errore di calcolo.
|
||||||
|
*/
|
||||||
|
export function ProjectSummary({
|
||||||
|
acceptedTotal,
|
||||||
|
payments,
|
||||||
|
phases,
|
||||||
|
totalTrackedSeconds,
|
||||||
|
targetHourlyRate,
|
||||||
|
}: Props) {
|
||||||
|
const contracted = parseFloat(acceptedTotal) || 0;
|
||||||
|
|
||||||
|
const collected = payments
|
||||||
|
.filter((p) => p.status === "saldato")
|
||||||
|
.reduce((sum, p) => sum + (parseFloat(String(p.amount)) || 0), 0);
|
||||||
|
|
||||||
|
const outstanding = payments
|
||||||
|
.filter((p) => p.status === "da_saldare" || p.status === "inviata")
|
||||||
|
.reduce((sum, p) => sum + (parseFloat(String(p.amount)) || 0), 0);
|
||||||
|
|
||||||
|
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
|
||||||
|
|
||||||
|
const allTasks = phases.flatMap((p) => p.tasks);
|
||||||
|
const doneTasks = allTasks.filter((t) => t.status === "done").length;
|
||||||
|
const progressPct =
|
||||||
|
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
|
||||||
|
const donePhases = phases.filter((p) => p.status === "done").length;
|
||||||
|
|
||||||
|
const hours = totalTrackedSeconds / 3600;
|
||||||
|
const realRate = hours > 0 ? contracted / hours : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 mb-8">
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Contrattualizzato"
|
||||||
|
value={fmtEur0(contracted)}
|
||||||
|
sub={payments.length > 0 ? `${payments.length} rate` : "Nessuna rata"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Incassato"
|
||||||
|
value={fmtEur0(collected)}
|
||||||
|
valueAccent={collected > 0}
|
||||||
|
sub={contracted > 0 ? `${collectedPct}% del totale` : undefined}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Da incassare"
|
||||||
|
value={fmtEur0(outstanding)}
|
||||||
|
sub={outstanding > 0 ? "Rate aperte" : "Tutto saldato"}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Redditività"
|
||||||
|
value={realRate === null ? "—" : `${fmtEur0(realRate)}/h`}
|
||||||
|
valueAccent={realRate !== null && realRate >= targetHourlyRate}
|
||||||
|
sub={
|
||||||
|
realRate === null
|
||||||
|
? "Nessuna ora tracciata"
|
||||||
|
: `${hours.toFixed(1)}h · target ${fmtEur0(targetHourlyRate)}/h`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avanzamento */}
|
||||||
|
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
|
||||||
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
||||||
|
Avanzamento
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{allTasks.length === 0 ? (
|
||||||
|
"Nessun task"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-mono text-foreground font-semibold">
|
||||||
|
{doneTasks}/{allTasks.length}
|
||||||
|
</span>{" "}
|
||||||
|
task · {donePhases}/{phases.length} fasi complete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<div
|
||||||
|
className="h-2 flex-1 rounded-full bg-muted overflow-hidden"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={progressPct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label="Avanzamento del progetto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-emerald-500 transition-[width]"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono font-bold text-foreground tabular-nums shrink-0">
|
||||||
|
{progressPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useTransition } from "react";
|
import { useState, useEffect, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTimer, startTimerForClient, stopTimer } from "@/app/admin/timer-actions";
|
import { startTimer, stopTimer } from "@/app/admin/timer-actions";
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@@ -13,17 +13,24 @@ function formatDuration(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TimerCell({
|
export function TimerCell({
|
||||||
clientId,
|
|
||||||
projectId,
|
projectId,
|
||||||
|
phaseId,
|
||||||
|
taskId,
|
||||||
activeEntryId,
|
activeEntryId,
|
||||||
activeStartedAt,
|
activeStartedAt,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
clientId: string;
|
projectId: string;
|
||||||
projectId?: string;
|
/** Fase e task su cui imputare il tempo. Assenti = tempo di progetto. */
|
||||||
|
phaseId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
/** Non-null solo se il timer attivo gira su QUESTO scope. */
|
||||||
activeEntryId: string | null;
|
activeEntryId: string | null;
|
||||||
activeStartedAt: Date | null;
|
activeStartedAt: Date | null;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
|
/** Variante ridotta, per stare in fondo alla riga di un task. */
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -48,10 +55,8 @@ export function TimerCell({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (isRunning && activeEntryId) {
|
if (isRunning && activeEntryId) {
|
||||||
await stopTimer(activeEntryId);
|
await stopTimer(activeEntryId);
|
||||||
} else if (projectId) {
|
|
||||||
await startTimer(projectId);
|
|
||||||
} else {
|
} else {
|
||||||
await startTimerForClient(clientId);
|
await startTimer(projectId, { phaseId, taskId });
|
||||||
}
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
@@ -59,9 +64,15 @@ export function TimerCell({
|
|||||||
|
|
||||||
const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0));
|
const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0));
|
||||||
|
|
||||||
|
// In compact il tempo si mostra solo se c'è: una riga di task con "0:00"
|
||||||
|
// accanto a ogni voce è rumore, e con venti task diventa una colonna di zeri.
|
||||||
|
const showTime = isRunning || totalTrackedSeconds > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`inline-flex items-center gap-2 rounded-full border pl-2 pr-3 py-1 text-xs font-mono tabular-nums transition-colors ${
|
className={`inline-flex items-center gap-2 rounded-full border transition-colors font-mono tabular-nums ${
|
||||||
|
compact ? "pl-1 pr-2 py-0.5 text-[11px]" : "pl-2 pr-3 py-1 text-xs"
|
||||||
|
} ${
|
||||||
isRunning
|
isRunning
|
||||||
? "bg-emerald-50/60 border-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900 dark:text-emerald-300 font-semibold"
|
? "bg-emerald-50/60 border-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900 dark:text-emerald-300 font-semibold"
|
||||||
: "bg-muted border-border text-muted-foreground"
|
: "bg-muted border-border text-muted-foreground"
|
||||||
@@ -70,7 +81,9 @@ export function TimerCell({
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={isRunning ? "Ferma timer" : "Avvia timer"}
|
title={isRunning ? "Ferma timer" : "Avvia timer"}
|
||||||
className={`w-5 h-5 rounded-full flex items-center justify-center transition-colors shrink-0 ${
|
className={`rounded-full flex items-center justify-center transition-colors shrink-0 ${
|
||||||
|
compact ? "w-4 h-4" : "w-5 h-5"
|
||||||
|
} ${
|
||||||
isRunning
|
isRunning
|
||||||
? "bg-emerald-500 text-white hover:bg-emerald-600"
|
? "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||||
: "bg-foreground/10 text-foreground hover:bg-foreground/20"
|
: "bg-foreground/10 text-foreground hover:bg-foreground/20"
|
||||||
@@ -90,7 +103,7 @@ export function TimerCell({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isRunning ? formatDuration(elapsed) : displayTotal}
|
{compact && !showTime ? null : isRunning ? formatDuration(elapsed) : displayTotal}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import type { Comment } from "@/db/schema";
|
|
||||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
comments: Comment[];
|
|
||||||
phases: ClientFullDetail["phases"];
|
|
||||||
clientId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function CommentsTab({ comments, phases, clientId }: Props) {
|
|
||||||
// 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) {
|
|
||||||
entityLabels[d.id] = `Deliverable: ${d.title}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build list of entities the admin can reply on
|
|
||||||
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) {
|
|
||||||
entities.push({
|
|
||||||
id: d.id,
|
|
||||||
type: "deliverable",
|
|
||||||
label: `Deliverable: ${d.title}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-lg">
|
|
||||||
{/* Comment list */}
|
|
||||||
{comments.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
|
|
||||||
)}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{comments.map((c) => (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
|
|
||||||
c.author === "admin"
|
|
||||||
? "bg-gray-900 text-white"
|
|
||||||
: "bg-white border border-gray-200 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium mb-1 opacity-60">
|
|
||||||
{c.author === "admin" ? "iamcavalli" : "Cliente"} —{" "}
|
|
||||||
{entityLabels[c.entity_id] ?? c.entity_id}
|
|
||||||
</p>
|
|
||||||
<p>{c.body}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin reply form */}
|
|
||||||
<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
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,11 +7,19 @@ import {
|
|||||||
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 { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
||||||
|
import { TimerCell } from "@/components/admin/TimerCell";
|
||||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
phases: ClientFullDetail["phases"];
|
phases: ClientFullDetail["phases"];
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
/** Assenti nella vista cliente: là le fasi si leggono, non si cronometrano. */
|
||||||
|
projectId?: string;
|
||||||
|
activeTimerEntryId?: string | null;
|
||||||
|
activeTimerStartedAt?: Date | null;
|
||||||
|
activeTimerTaskId?: string | null;
|
||||||
|
taskSeconds?: Record<string, number>;
|
||||||
|
phaseSeconds?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskStatusOptions = [
|
const taskStatusOptions = [
|
||||||
@@ -26,7 +34,25 @@ const phaseStatusOptions = [
|
|||||||
{ value: "done", label: "Completata" },
|
{ value: "done", label: "Completata" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function PhasesTab({ phases, clientId }: Props) {
|
/** "3h 20m" · "45m" · "—" quando non c'è tempo tracciato. */
|
||||||
|
function formatHours(seconds: number): string {
|
||||||
|
if (seconds <= 0) return "—";
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.round((seconds % 3600) / 60);
|
||||||
|
if (h === 0) return `${m}m`;
|
||||||
|
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PhasesTab({
|
||||||
|
phases,
|
||||||
|
clientId,
|
||||||
|
projectId,
|
||||||
|
activeTimerEntryId = null,
|
||||||
|
activeTimerStartedAt = null,
|
||||||
|
activeTimerTaskId = null,
|
||||||
|
taskSeconds = {},
|
||||||
|
phaseSeconds = {},
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Add phase form */}
|
{/* Add phase form */}
|
||||||
@@ -50,15 +76,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
|||||||
|
|
||||||
{/* Phases list */}
|
{/* Phases list */}
|
||||||
{phases.length === 0 && (
|
{phases.length === 0 && (
|
||||||
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
|
<p className="text-sm text-muted-foreground">Nessuna fase ancora.</p>
|
||||||
)}
|
)}
|
||||||
{phases.map((phase) => (
|
{phases.map((phase) => (
|
||||||
<div
|
<div
|
||||||
key={phase.id}
|
key={phase.id}
|
||||||
className="border border-gray-200 rounded-lg p-4 bg-white"
|
className="border border-border rounded-lg p-4 bg-card"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
||||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h3 className="font-semibold text-foreground">{phase.title}</h3>
|
||||||
|
{projectId && (
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono tabular-nums text-muted-foreground"
|
||||||
|
title="Tempo tracciato su questa fase"
|
||||||
|
>
|
||||||
|
{formatHours(phaseSeconds[phase.id] ?? 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
@@ -74,7 +110,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
|||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
defaultValue={phase.status}
|
defaultValue={phase.status}
|
||||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
|
||||||
>
|
>
|
||||||
{phaseStatusOptions.map((o) => (
|
{phaseStatusOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
@@ -95,10 +131,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
|||||||
{phase.tasks.map((task) => (
|
{phase.tasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
|
className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-gray-800">{task.title}</span>
|
<span className="text-sm text-foreground">{task.title}</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{projectId && (
|
||||||
|
<TimerCell
|
||||||
|
projectId={projectId}
|
||||||
|
phaseId={phase.id}
|
||||||
|
taskId={task.id}
|
||||||
|
activeEntryId={
|
||||||
|
activeTimerTaskId === task.id ? activeTimerEntryId : null
|
||||||
|
}
|
||||||
|
activeStartedAt={
|
||||||
|
activeTimerTaskId === task.id ? activeTimerStartedAt : null
|
||||||
|
}
|
||||||
|
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -113,7 +164,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
|||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
defaultValue={task.status}
|
defaultValue={task.status}
|
||||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
|
||||||
>
|
>
|
||||||
{taskStatusOptions.map((o) => (
|
{taskStatusOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type TimerTabProps = {
|
|||||||
acceptedTotal: string;
|
acceptedTotal: string;
|
||||||
activeTimerEntryId: string | null;
|
activeTimerEntryId: string | null;
|
||||||
activeTimerStartedAt: Date | null;
|
activeTimerStartedAt: Date | null;
|
||||||
|
activeTimerScoped: boolean;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
targetHourlyRate: number;
|
targetHourlyRate: number;
|
||||||
recentEntries: TimeEntry[];
|
recentEntries: TimeEntry[];
|
||||||
@@ -26,19 +27,29 @@ export function TimerTab({
|
|||||||
acceptedTotal,
|
acceptedTotal,
|
||||||
activeTimerEntryId,
|
activeTimerEntryId,
|
||||||
activeTimerStartedAt,
|
activeTimerStartedAt,
|
||||||
|
activeTimerScoped,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
targetHourlyRate,
|
targetHourlyRate,
|
||||||
recentEntries,
|
recentEntries,
|
||||||
}: TimerTabProps) {
|
}: TimerTabProps) {
|
||||||
|
// Il timer attivo è uno solo in tutto l'hub. Se sta girando su un task, qui
|
||||||
|
// NON va mostrato come acceso: questo è il timer di progetto, e mostrarlo
|
||||||
|
// acceso farebbe credere che siano due cronometri diversi.
|
||||||
|
const projectEntryId = activeTimerScoped ? null : activeTimerEntryId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-sm">
|
<div className="space-y-6 max-w-sm">
|
||||||
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
|
<div className="bg-card rounded-lg border border-border p-4">
|
||||||
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
|
<h3 className="font-medium text-foreground mb-1">Timer di progetto</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
{activeTimerScoped
|
||||||
|
? "Un timer sta girando su un task, in «Fasi & Task». Avviando questo, quello si ferma."
|
||||||
|
: "Il tempo avviato qui non è imputato a nessuna fase. Per attribuirlo, usa il timer sul singolo task in «Fasi & Task»."}
|
||||||
|
</p>
|
||||||
<TimerCell
|
<TimerCell
|
||||||
clientId={projectId}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
activeEntryId={activeTimerEntryId}
|
activeEntryId={projectEntryId}
|
||||||
activeStartedAt={activeTimerStartedAt}
|
activeStartedAt={projectEntryId ? activeTimerStartedAt : null}
|
||||||
totalTrackedSeconds={totalTrackedSeconds}
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Additive: timer per fase/task e data di consegna attesa sul progetto.
|
||||||
|
--
|
||||||
|
-- ── time_entries.phase_id / task_id ──────────────────────────────────────────
|
||||||
|
-- Il timer nasce a livello di progetto: time_entries aveva la sola project_id,
|
||||||
|
-- quindi "quanto e' costata questa fase" non era una domanda che si potesse
|
||||||
|
-- fare. Le due colonne sono NULLABLE e project_id resta obbligatoria: ogni
|
||||||
|
-- entry e' sempre attribuita a un progetto, il dettaglio e' un di piu'.
|
||||||
|
-- Le righe esistenti restano valide con entrambe a NULL, cioe' "tempo di
|
||||||
|
-- progetto, non imputato a una fase" — che e' esattamente cio' che sono.
|
||||||
|
--
|
||||||
|
-- ON DELETE SET NULL, non CASCADE, ed e' la scelta che conta qui: cancellare un
|
||||||
|
-- task NON deve cancellare il tempo tracciato su di esso. Sono ore lavorate, e
|
||||||
|
-- quindi storico fatturabile; l'entry ricade a livello progetto e il totale del
|
||||||
|
-- progetto non cambia mai. Con CASCADE, ripulire una fase avrebbe silenziosamente
|
||||||
|
-- abbassato il fatturato tracciato.
|
||||||
|
--
|
||||||
|
-- ── projects.due_date ────────────────────────────────────────────────────────
|
||||||
|
-- La consegna attesa e' normalmente DERIVATA: project_offers.start_date +
|
||||||
|
-- offer_micros.duration_months. Questa colonna e' l'override manuale, e vince
|
||||||
|
-- sulla derivata quando e' valorizzata. Nullable per forza: la stragrande
|
||||||
|
-- maggioranza dei progetti continuera' a non averla, ed e' giusto cosi' —
|
||||||
|
-- un campo obbligatorio in piu' su ogni progetto e' un campo che invecchia.
|
||||||
|
--
|
||||||
|
-- Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa o modificata.
|
||||||
|
-- Applicare a prod via SSH+docker exec PRIMA di pushare il codice dipendente.
|
||||||
|
-- Idempotente: safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE time_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS phase_id text REFERENCES phases(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE time_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS task_id text REFERENCES tasks(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Il rollup per fase raggruppa su queste due colonne a ogni apertura del
|
||||||
|
-- progetto: senza indice diventa una scansione piena appena le entry crescono.
|
||||||
|
CREATE INDEX IF NOT EXISTS time_entries_phase_idx ON time_entries (phase_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS time_entries_task_idx ON time_entries (task_id);
|
||||||
|
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN IF NOT EXISTS due_date timestamptz;
|
||||||
+31
-11
@@ -109,6 +109,10 @@ export const projects = pgTable("projects", {
|
|||||||
offer_id: text("offer_id")
|
offer_id: text("offer_id")
|
||||||
.references(() => offer_micros.id, { onDelete: "set null" }),
|
.references(() => offer_micros.id, { onDelete: "set null" }),
|
||||||
created_from_lead_id: text("created_from_lead_id"),
|
created_from_lead_id: text("created_from_lead_id"),
|
||||||
|
// Consegna attesa. Normalmente si DERIVA da project_offers.start_date +
|
||||||
|
// offer_micros.duration_months; questa colonna è l'override manuale e vince
|
||||||
|
// sulla derivata quando è valorizzata (migration 0018).
|
||||||
|
due_date: timestamp("due_date", { withTimezone: true }),
|
||||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,17 +246,33 @@ export const notes = pgTable("notes", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ============ TIME ENTRIES (admin time tracking per project) ============
|
// ============ TIME ENTRIES (admin time tracking per project) ============
|
||||||
export const time_entries = pgTable("time_entries", {
|
// project_id è obbligatoria: ogni entry è sempre attribuita a un progetto.
|
||||||
id: text("id")
|
// phase_id/task_id (migration 0018) sono il dettaglio facoltativo — NULL su
|
||||||
.primaryKey()
|
// tutte le righe precedenti, che sono "tempo di progetto" e restano tali.
|
||||||
.$defaultFn(() => nanoid()),
|
//
|
||||||
project_id: text("project_id")
|
// ON DELETE SET NULL, non cascade: cancellare un task non deve cancellare le
|
||||||
.notNull()
|
// ore lavorate su di esso. L'entry ricade a livello progetto, il totale non
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
// cambia mai.
|
||||||
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
export const time_entries = pgTable(
|
||||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
"time_entries",
|
||||||
duration_seconds: integer("duration_seconds"), // set on stop
|
{
|
||||||
});
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
project_id: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
phase_id: text("phase_id").references(() => phases.id, { onDelete: "set null" }),
|
||||||
|
task_id: text("task_id").references(() => tasks.id, { onDelete: "set null" }),
|
||||||
|
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||||
|
duration_seconds: integer("duration_seconds"), // set on stop
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("time_entries_phase_idx").on(t.phase_id),
|
||||||
|
index("time_entries_task_idx").on(t.task_id),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// ============ SERVICE CATALOG (admin-only, used for quote generation) ============
|
// ============ SERVICE CATALOG (admin-only, used for quote generation) ============
|
||||||
export const service_catalog = pgTable("service_catalog", {
|
export const service_catalog = pgTable("service_catalog", {
|
||||||
|
|||||||
+52
-30
@@ -25,7 +25,7 @@ import {
|
|||||||
clientTranscripts,
|
clientTranscripts,
|
||||||
client_emails,
|
client_emails,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, ne, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
import { eq, ne, inArray, asc, desc, isNull, isNotNull, sql, and } from "drizzle-orm";
|
||||||
import { getPool } from "@/lib/taxonomy";
|
import { getPool } from "@/lib/taxonomy";
|
||||||
import { LEAD_STAGES } from "@/lib/lead-validators";
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||||
import type {
|
import type {
|
||||||
@@ -477,8 +477,8 @@ export type ProjectWithPayments = {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
||||||
activeTimerEntryId: string | null;
|
// Niente timer attivo qui: la lista non lo mostra più, si avvia e si ferma
|
||||||
activeTimerStartedAt: Date | null;
|
// dentro il progetto. `totalTrackedSeconds` resta perché serve al €/h.
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -506,21 +506,12 @@ export async function getAllProjectsWithPayments(
|
|||||||
const projectIds = visible.map((p) => p.id);
|
const projectIds = visible.map((p) => p.id);
|
||||||
const clientIds = [...new Set(visible.map((p) => p.client_id))];
|
const clientIds = [...new Set(visible.map((p) => p.client_id))];
|
||||||
|
|
||||||
const [allPayments, activeEntries, totals, parentClients] = await Promise.all([
|
const [allPayments, totals, parentClients] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(inArray(payments.project_id, projectIds)),
|
.where(inArray(payments.project_id, projectIds)),
|
||||||
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: time_entries.id,
|
|
||||||
project_id: time_entries.project_id,
|
|
||||||
started_at: time_entries.started_at,
|
|
||||||
})
|
|
||||||
.from(time_entries)
|
|
||||||
.where(isNull(time_entries.ended_at)),
|
|
||||||
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
project_id: time_entries.project_id,
|
project_id: time_entries.project_id,
|
||||||
@@ -538,7 +529,6 @@ export async function getAllProjectsWithPayments(
|
|||||||
|
|
||||||
return visible.map((project) => {
|
return visible.map((project) => {
|
||||||
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
|
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
|
||||||
const activeEntry = activeEntries.find((e) => e.project_id === project.id);
|
|
||||||
const totalRow = totals.find((t) => t.project_id === project.id);
|
const totalRow = totals.find((t) => t.project_id === project.id);
|
||||||
const parentClient = parentClients.find((c) => c.id === project.client_id);
|
const parentClient = parentClients.find((c) => c.id === project.client_id);
|
||||||
|
|
||||||
@@ -555,8 +545,6 @@ export async function getAllProjectsWithPayments(
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
amount: String(p.amount),
|
amount: String(p.amount),
|
||||||
})),
|
})),
|
||||||
activeTimerEntryId: activeEntry?.id ?? null,
|
|
||||||
activeTimerStartedAt: activeEntry?.started_at ?? null,
|
|
||||||
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
|
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -570,12 +558,19 @@ export type ProjectFullDetail = {
|
|||||||
payments: Payment[];
|
payments: Payment[];
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
comments: Comment[];
|
// I commenti NON stanno qui: si leggono e si risponde da /admin/conversazioni,
|
||||||
|
// che li aggrega per cliente con l'etichetta dell'entità di origine.
|
||||||
quoteItems: QuoteItemWithLabel[];
|
quoteItems: QuoteItemWithLabel[];
|
||||||
activeServices: Service[];
|
activeServices: Service[];
|
||||||
activeTimerEntryId: string | null;
|
activeTimerEntryId: string | null;
|
||||||
activeTimerStartedAt: Date | null;
|
activeTimerStartedAt: Date | null;
|
||||||
|
/** Su quale fase/task sta girando il timer attivo — null se è a livello progetto. */
|
||||||
|
activeTimerPhaseId: string | null;
|
||||||
|
activeTimerTaskId: string | null;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
|
/** Secondi tracciati per task e per fase (id → secondi). Assente = zero. */
|
||||||
|
taskSeconds: Record<string, number>;
|
||||||
|
phaseSeconds: Record<string, number>;
|
||||||
projectOffers: ProjectOfferWithMicro[];
|
projectOffers: ProjectOfferWithMicro[];
|
||||||
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
||||||
offersAcceptedTotal: number;
|
offersAcceptedTotal: number;
|
||||||
@@ -644,7 +639,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
.from(deliverables)
|
.from(deliverables)
|
||||||
.where(inArray(deliverables.task_id, taskIds));
|
.where(inArray(deliverables.task_id, taskIds));
|
||||||
|
|
||||||
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, taskSecondsRows, phaseSecondsRows, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.select().from(payments).where(eq(payments.project_id, id)),
|
db.select().from(payments).where(eq(payments.project_id, id)),
|
||||||
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
||||||
@@ -665,7 +660,12 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
.orderBy(asc(quote_items.id)),
|
.orderBy(asc(quote_items.id)),
|
||||||
db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)),
|
db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)),
|
||||||
db
|
db
|
||||||
.select({ id: time_entries.id, started_at: time_entries.started_at })
|
.select({
|
||||||
|
id: time_entries.id,
|
||||||
|
started_at: time_entries.started_at,
|
||||||
|
phase_id: time_entries.phase_id,
|
||||||
|
task_id: time_entries.task_id,
|
||||||
|
})
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
|
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
|
||||||
.limit(1),
|
.limit(1),
|
||||||
@@ -673,6 +673,26 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
|
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(eq(time_entries.project_id, id)),
|
.where(eq(time_entries.project_id, id)),
|
||||||
|
// Rollup per task e per fase. Il timer su un task scrive ENTRAMBE le
|
||||||
|
// colonne (vedi startTimer), quindi il totale di fase è un group-by
|
||||||
|
// diretto su phase_id e comprende anche il tempo imputato alla fase ma a
|
||||||
|
// nessun task in particolare.
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
task_id: time_entries.task_id,
|
||||||
|
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
||||||
|
})
|
||||||
|
.from(time_entries)
|
||||||
|
.where(and(eq(time_entries.project_id, id), isNotNull(time_entries.task_id)))
|
||||||
|
.groupBy(time_entries.task_id),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
phase_id: time_entries.phase_id,
|
||||||
|
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
||||||
|
})
|
||||||
|
.from(time_entries)
|
||||||
|
.where(and(eq(time_entries.project_id, id), isNotNull(time_entries.phase_id)))
|
||||||
|
.groupBy(time_entries.phase_id),
|
||||||
// Query A: project offers for this project joined with micro + macro info
|
// Query A: project offers for this project joined with micro + macro info
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -734,16 +754,6 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
.orderBy(desc(clientTranscripts.call_date)),
|
.orderBy(desc(clientTranscripts.call_date)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
|
||||||
const commentsRows =
|
|
||||||
allEntityIds.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select()
|
|
||||||
.from(comments)
|
|
||||||
.where(inArray(comments.entity_id, allEntityIds))
|
|
||||||
.orderBy(asc(comments.created_at));
|
|
||||||
|
|
||||||
const phasesWithTasks = phasesRows.map((phase) => ({
|
const phasesWithTasks = phasesRows.map((phase) => ({
|
||||||
...phase,
|
...phase,
|
||||||
tasks: tasksRows
|
tasks: tasksRows
|
||||||
@@ -754,6 +764,15 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const taskSeconds: Record<string, number> = {};
|
||||||
|
for (const row of taskSecondsRows) {
|
||||||
|
if (row.task_id) taskSeconds[row.task_id] = parseInt(row.total);
|
||||||
|
}
|
||||||
|
const phaseSeconds: Record<string, number> = {};
|
||||||
|
for (const row of phaseSecondsRows) {
|
||||||
|
if (row.phase_id) phaseSeconds[row.phase_id] = parseInt(row.total);
|
||||||
|
}
|
||||||
|
|
||||||
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
|
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
|
||||||
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
|
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
|
||||||
const seenTier = new Set<string>();
|
const seenTier = new Set<string>();
|
||||||
@@ -780,12 +799,15 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
payments: paymentsRows,
|
payments: paymentsRows,
|
||||||
documents: documentsRows,
|
documents: documentsRows,
|
||||||
notes: notesRows,
|
notes: notesRows,
|
||||||
comments: commentsRows,
|
|
||||||
quoteItems: quoteItemRows as QuoteItemWithLabel[],
|
quoteItems: quoteItemRows as QuoteItemWithLabel[],
|
||||||
activeServices: activeServiceRows,
|
activeServices: activeServiceRows,
|
||||||
activeTimerEntryId: activeEntryRows[0]?.id ?? null,
|
activeTimerEntryId: activeEntryRows[0]?.id ?? null,
|
||||||
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
||||||
|
activeTimerPhaseId: activeEntryRows[0]?.phase_id ?? null,
|
||||||
|
activeTimerTaskId: activeEntryRows[0]?.task_id ?? null,
|
||||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||||
|
taskSeconds,
|
||||||
|
phaseSeconds,
|
||||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||||
offersAcceptedTotal,
|
offersAcceptedTotal,
|
||||||
availableMicros: dedupedMicros,
|
availableMicros: dedupedMicros,
|
||||||
|
|||||||
Reference in New Issue
Block a user