Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6e95ef66a | |||
| a9358da96f | |||
| 4b135ce67f |
@@ -18,7 +18,6 @@ import {
|
||||
payments,
|
||||
clients,
|
||||
projects,
|
||||
comments,
|
||||
client_emails,
|
||||
otp_codes,
|
||||
} from "@/db/schema";
|
||||
@@ -368,21 +367,11 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
|
||||
|
||||
export async function postAdminComment(id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const entity = formData.get("entity") as string;
|
||||
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);
|
||||
}
|
||||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||
// La risposta dell'admin vive in `replyToConversation`
|
||||
// (src/app/admin/conversazioni/actions.ts): unica inbox, un solo punto di
|
||||
// scrittura. Qui c'era `postAdminComment`, che rispondeva sulla singola entità
|
||||
// dal tab Commenti del progetto — tab rimosso, action con lui.
|
||||
|
||||
// ── ACCESSI PORTALE (whitelist OTP) ──────────────────────────────────────────
|
||||
// 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
|
||||
* 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) {
|
||||
await requireAdmin();
|
||||
|
||||
+1
-45
@@ -12,54 +12,10 @@ import { YearSelector } from "@/components/admin/YearSelector";
|
||||
import { ForecastChart } from "@/components/admin/ForecastChart";
|
||||
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
||||
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
|
||||
|
||||
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({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||
import { ProjectSummary } from "@/components/admin/ProjectSummary";
|
||||
import Link from "next/link";
|
||||
|
||||
export const revalidate = 0;
|
||||
@@ -34,10 +34,13 @@ export default async function ProjectDetailPage({
|
||||
payments,
|
||||
documents,
|
||||
notes,
|
||||
comments,
|
||||
activeTimerEntryId,
|
||||
activeTimerStartedAt,
|
||||
activeTimerPhaseId,
|
||||
activeTimerTaskId,
|
||||
totalTrackedSeconds,
|
||||
taskSeconds,
|
||||
phaseSeconds,
|
||||
projectOffers,
|
||||
availableMicros,
|
||||
offersAcceptedTotal,
|
||||
@@ -66,20 +69,38 @@ export default async function ProjectDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectSummary
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
payments={payments}
|
||||
phases={phases}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="phases">
|
||||
<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}
|
||||
clientId={id}
|
||||
/>
|
||||
@@ -115,16 +136,13 @@ export default async function ProjectDetailPage({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comments">
|
||||
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timer">
|
||||
<TimerTab
|
||||
projectId={id}
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
activeTimerEntryId={activeTimerEntryId}
|
||||
activeTimerStartedAt={activeTimerStartedAt}
|
||||
activeTimerScoped={activeTimerPhaseId !== null || activeTimerTaskId !== null}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
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-center">Acconto</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>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -9,11 +9,26 @@ async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
import { time_entries, projects } from "@/db/schema";
|
||||
import { eq, isNull, asc } from "drizzle-orm";
|
||||
import { time_entries } from "@/db/schema";
|
||||
import { eq, isNull } from "drizzle-orm";
|
||||
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();
|
||||
// Stop any currently running session before starting a new one
|
||||
const running = await db
|
||||
@@ -38,26 +53,18 @@ export async function startTimer(projectId: string): Promise<{ entryId: string }
|
||||
}
|
||||
|
||||
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/projects");
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
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> {
|
||||
await requireAdmin();
|
||||
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 { TimerCell } from "@/components/admin/TimerCell";
|
||||
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
@@ -80,18 +79,6 @@ export function ProjectRow({ project }: { project: ProjectWithPayments }) {
|
||||
)}
|
||||
</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">
|
||||
{eurPerHour === null ? (
|
||||
<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 { 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 {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -13,17 +13,24 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
|
||||
export function TimerCell({
|
||||
clientId,
|
||||
projectId,
|
||||
phaseId,
|
||||
taskId,
|
||||
activeEntryId,
|
||||
activeStartedAt,
|
||||
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;
|
||||
activeStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
/** Variante ridotta, per stare in fondo alla riga di un task. */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -48,10 +55,8 @@ export function TimerCell({
|
||||
startTransition(async () => {
|
||||
if (isRunning && activeEntryId) {
|
||||
await stopTimer(activeEntryId);
|
||||
} else if (projectId) {
|
||||
await startTimer(projectId);
|
||||
} else {
|
||||
await startTimerForClient(clientId);
|
||||
await startTimer(projectId, { phaseId, taskId });
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
@@ -59,9 +64,15 @@ export function TimerCell({
|
||||
|
||||
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 (
|
||||
<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
|
||||
? "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"
|
||||
@@ -70,7 +81,9 @@ export function TimerCell({
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
: "bg-foreground/10 text-foreground hover:bg-foreground/20"
|
||||
@@ -90,7 +103,7 @@ export function TimerCell({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRunning ? formatDuration(elapsed) : displayTotal}
|
||||
{compact && !showTime ? null : isRunning ? formatDuration(elapsed) : displayTotal}
|
||||
</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 { Input } from "@/components/ui/input";
|
||||
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
||||
import { TimerCell } from "@/components/admin/TimerCell";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
phases: ClientFullDetail["phases"];
|
||||
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 = [
|
||||
@@ -26,7 +34,25 @@ const phaseStatusOptions = [
|
||||
{ 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Add phase form */}
|
||||
@@ -50,15 +76,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
|
||||
{/* Phases list */}
|
||||
{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) => (
|
||||
<div
|
||||
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">
|
||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
||||
<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">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
@@ -74,7 +110,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
<select
|
||||
name="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) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -95,10 +131,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
{phase.tasks.map((task) => (
|
||||
<div
|
||||
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">
|
||||
{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
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -113,7 +164,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
<select
|
||||
name="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) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -158,4 +209,4 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type TimerTabProps = {
|
||||
acceptedTotal: string;
|
||||
activeTimerEntryId: string | null;
|
||||
activeTimerStartedAt: Date | null;
|
||||
activeTimerScoped: boolean;
|
||||
totalTrackedSeconds: number;
|
||||
targetHourlyRate: number;
|
||||
recentEntries: TimeEntry[];
|
||||
@@ -26,19 +27,29 @@ export function TimerTab({
|
||||
acceptedTotal,
|
||||
activeTimerEntryId,
|
||||
activeTimerStartedAt,
|
||||
activeTimerScoped,
|
||||
totalTrackedSeconds,
|
||||
targetHourlyRate,
|
||||
recentEntries,
|
||||
}: 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 (
|
||||
<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>
|
||||
<div className="bg-card rounded-lg border border-border p-4">
|
||||
<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
|
||||
clientId={projectId}
|
||||
projectId={projectId}
|
||||
activeEntryId={activeTimerEntryId}
|
||||
activeStartedAt={activeTimerStartedAt}
|
||||
activeEntryId={projectEntryId}
|
||||
activeStartedAt={projectEntryId ? activeTimerStartedAt : null}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
/>
|
||||
</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")
|
||||
.references(() => offer_micros.id, { onDelete: "set null" }),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -242,17 +246,33 @@ export const notes = pgTable("notes", {
|
||||
});
|
||||
|
||||
// ============ TIME ENTRIES (admin time tracking per project) ============
|
||||
export const time_entries = pgTable("time_entries", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||
duration_seconds: integer("duration_seconds"), // set on stop
|
||||
});
|
||||
// project_id è obbligatoria: ogni entry è sempre attribuita a un progetto.
|
||||
// phase_id/task_id (migration 0018) sono il dettaglio facoltativo — NULL su
|
||||
// tutte le righe precedenti, che sono "tempo di progetto" e restano tali.
|
||||
//
|
||||
// ON DELETE SET NULL, non cascade: cancellare un task non deve cancellare le
|
||||
// ore lavorate su di esso. L'entry ricade a livello progetto, il totale non
|
||||
// cambia mai.
|
||||
export const time_entries = pgTable(
|
||||
"time_entries",
|
||||
{
|
||||
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) ============
|
||||
export const service_catalog = pgTable("service_catalog", {
|
||||
|
||||
+52
-30
@@ -25,7 +25,7 @@ import {
|
||||
clientTranscripts,
|
||||
client_emails,
|
||||
} 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 { LEAD_STAGES } from "@/lib/lead-validators";
|
||||
import type {
|
||||
@@ -477,8 +477,8 @@ export type ProjectWithPayments = {
|
||||
archived: boolean;
|
||||
created_at: Date;
|
||||
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
||||
activeTimerEntryId: string | null;
|
||||
activeTimerStartedAt: Date | null;
|
||||
// Niente timer attivo qui: la lista non lo mostra più, si avvia e si ferma
|
||||
// dentro il progetto. `totalTrackedSeconds` resta perché serve al €/h.
|
||||
totalTrackedSeconds: number;
|
||||
};
|
||||
|
||||
@@ -506,21 +506,12 @@ export async function getAllProjectsWithPayments(
|
||||
const projectIds = visible.map((p) => p.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
|
||||
.select()
|
||||
.from(payments)
|
||||
.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
|
||||
.select({
|
||||
project_id: time_entries.project_id,
|
||||
@@ -538,7 +529,6 @@ export async function getAllProjectsWithPayments(
|
||||
|
||||
return visible.map((project) => {
|
||||
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 parentClient = parentClients.find((c) => c.id === project.client_id);
|
||||
|
||||
@@ -555,8 +545,6 @@ export async function getAllProjectsWithPayments(
|
||||
status: p.status,
|
||||
amount: String(p.amount),
|
||||
})),
|
||||
activeTimerEntryId: activeEntry?.id ?? null,
|
||||
activeTimerStartedAt: activeEntry?.started_at ?? null,
|
||||
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
|
||||
};
|
||||
});
|
||||
@@ -570,12 +558,19 @@ export type ProjectFullDetail = {
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
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[];
|
||||
activeServices: Service[];
|
||||
activeTimerEntryId: string | 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;
|
||||
/** Secondi tracciati per task e per fase (id → secondi). Assente = zero. */
|
||||
taskSeconds: Record<string, number>;
|
||||
phaseSeconds: Record<string, number>;
|
||||
projectOffers: ProjectOfferWithMicro[];
|
||||
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
||||
offersAcceptedTotal: number;
|
||||
@@ -644,7 +639,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.from(deliverables)
|
||||
.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([
|
||||
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)),
|
||||
@@ -665,7 +660,12 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.orderBy(asc(quote_items.id)),
|
||||
db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)),
|
||||
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)
|
||||
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
|
||||
.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)` })
|
||||
.from(time_entries)
|
||||
.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
|
||||
db
|
||||
.select({
|
||||
@@ -734,16 +754,6 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.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) => ({
|
||||
...phase,
|
||||
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,
|
||||
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
|
||||
const seenTier = new Set<string>();
|
||||
@@ -780,12 +799,15 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
payments: paymentsRows,
|
||||
documents: documentsRows,
|
||||
notes: notesRows,
|
||||
comments: commentsRows,
|
||||
quoteItems: quoteItemRows as QuoteItemWithLabel[],
|
||||
activeServices: activeServiceRows,
|
||||
activeTimerEntryId: activeEntryRows[0]?.id ?? 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,
|
||||
taskSeconds,
|
||||
phaseSeconds,
|
||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||
offersAcceptedTotal,
|
||||
availableMicros: dedupedMicros,
|
||||
|
||||
Reference in New Issue
Block a user