diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 4ae1ad5..94850f5 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -16,6 +16,31 @@ import { import { eq, asc, inArray } from "drizzle-orm"; import { z } from "zod"; +// ── ENTITY RESOLUTION ──────────────────────────────────────────────────────── +// Both clientId and projectId are passed as "clientId" by tab components. +// This helper resolves the correct projectId and revalidation path for either. + +async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> { + const asProject = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, id)) + .limit(1); + + if (asProject[0]) { + return { projectId: id, path: `/admin/projects/${id}` }; + } + + const asClient = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.client_id, id)) + .orderBy(asc(projects.created_at)) + .limit(1); + + return { projectId: asClient[0]?.id ?? null, path: `/admin/clients/${id}` }; +} + // ── CLIENT CRUD ─────────────────────────────────────────────────────────────── const clientSchema = z.object({ @@ -50,22 +75,12 @@ export async function setClientArchived(clientId: string, archived: boolean) { // ── PHASES ──────────────────────────────────────────────────────────────────── -async function getDefaultProjectId(clientId: string): Promise { - const rows = await db - .select({ id: projects.id }) - .from(projects) - .where(eq(projects.client_id, clientId)) - .orderBy(asc(projects.created_at)) - .limit(1); - return rows[0]?.id ?? null; -} - -export async function addPhase(clientId: string, formData: FormData) { +export async function addPhase(id: string, formData: FormData) { const title = (formData.get("title") as string)?.trim(); if (!title) throw new Error("Titolo fase richiesto"); - const projectId = await getDefaultProjectId(clientId); - if (!projectId) throw new Error("Nessun progetto trovato per questo cliente"); + const { projectId, path } = await resolveEntity(id); + if (!projectId) throw new Error("Nessun progetto trovato"); const existingPhases = await db .select({ sort_order: phases.sort_order }) @@ -79,27 +94,20 @@ export async function addPhase(clientId: string, formData: FormData) { sort_order: maxOrder + 1, status: "upcoming", }); - revalidatePath(`/admin/clients/${clientId}`); + revalidatePath(path); } -export async function updatePhaseStatus( - phaseId: string, - clientId: string, - status: string -) { +export async function updatePhaseStatus(phaseId: string, id: string, status: string) { const allowed = ["upcoming", "active", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(phases).set({ status }).where(eq(phases.id, phaseId)); - revalidatePath(`/admin/clients/${clientId}`); + const { path } = await resolveEntity(id); + revalidatePath(path); } // ── TASKS ───────────────────────────────────────────────────────────────────── -export async function addTask( - phaseId: string, - clientId: string, - formData: FormData -) { +export async function addTask(phaseId: string, id: string, formData: FormData) { const title = (formData.get("title") as string)?.trim(); if (!title) throw new Error("Titolo task richiesto"); @@ -116,35 +124,27 @@ export async function addTask( sort_order: maxOrder + 1, status: "todo", }); - revalidatePath(`/admin/clients/${clientId}`); + const { path } = await resolveEntity(id); + revalidatePath(path); } -export async function updateTaskStatus( - taskId: string, - clientId: string, - status: string -) { +export async function updateTaskStatus(taskId: string, id: string, status: string) { const allowed = ["todo", "in_progress", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); - revalidatePath(`/admin/clients/${clientId}`); + const { path } = await resolveEntity(id); + revalidatePath(path); } // ── DELIVERABLES ────────────────────────────────────────────────────────────── -export async function addDeliverable( - taskId: string, - clientId: string, - formData: FormData -) { +export async function addDeliverable(taskId: string, id: string, formData: FormData) { const title = (formData.get("title") as string)?.trim(); const url = (formData.get("url") as string)?.trim() || null; if (!title) throw new Error("Titolo deliverable richiesto"); - // approved_at is intentionally omitted — immutable constraint: never set by admin here - await db - .insert(deliverables) - .values({ task_id: taskId, title, url, status: "pending" }); - revalidatePath(`/admin/clients/${clientId}`); + await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" }); + const { path } = await resolveEntity(id); + revalidatePath(path); } // ── DOCUMENTS ───────────────────────────────────────────────────────────────── @@ -154,94 +154,90 @@ const docSchema = z.object({ url: z.string().url("URL non valido"), }); -export async function addDocument(clientId: string, formData: FormData) { +export async function addDocument(id: string, formData: FormData) { const parsed = docSchema.safeParse({ label: formData.get("label"), url: formData.get("url"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); - const projectId = await getDefaultProjectId(clientId); - if (!projectId) throw new Error("Nessun progetto trovato per questo cliente"); + const { projectId, path } = await resolveEntity(id); + if (!projectId) throw new Error("Nessun progetto trovato"); await db.insert(documents).values({ project_id: projectId, ...parsed.data }); - revalidatePath(`/admin/clients/${clientId}`); + revalidatePath(path); } -export async function updateDocument( - documentId: string, - clientId: string, - formData: FormData -) { +export async function updateDocument(documentId: string, id: string, formData: FormData) { const parsed = docSchema.safeParse({ label: formData.get("label"), url: formData.get("url"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); - await db - .update(documents) - .set(parsed.data) - .where(eq(documents.id, documentId)); - revalidatePath(`/admin/clients/${clientId}`); + await db.update(documents).set(parsed.data).where(eq(documents.id, documentId)); + const { path } = await resolveEntity(id); + revalidatePath(path); } -export async function deleteDocument(documentId: string, clientId: string) { +export async function deleteDocument(documentId: string, id: string) { await db.delete(documents).where(eq(documents.id, documentId)); - revalidatePath(`/admin/clients/${clientId}`); + const { path } = await resolveEntity(id); + revalidatePath(path); } // ── PAYMENTS ────────────────────────────────────────────────────────────────── -export async function updatePaymentStatus( - paymentId: string, - clientId: string, - status: string -) { +export async function updatePaymentStatus(paymentId: string, id: string, status: string) { const allowed = ["da_saldare", "inviata", "saldato"]; if (!allowed.includes(status)) throw new Error("Stato pagamento non valido"); const paid_at = status === "saldato" ? new Date() : null; - await db - .update(payments) - .set({ status, paid_at }) - .where(eq(payments.id, paymentId)); - revalidatePath(`/admin/clients/${clientId}`); + await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId)); + const { path } = await resolveEntity(id); + revalidatePath(path); } -export async function updateAcceptedTotal(clientId: string, formData: FormData) { +export async function updateAcceptedTotal(id: string, formData: FormData) { const raw = (formData.get("accepted_total") as string)?.trim(); const val = parseFloat(raw); if (isNaN(val) || val < 0) throw new Error("Importo non valido"); - // Update accepted_total on client row — denormalized field, quote_items never exposed - await db - .update(clients) - .set({ accepted_total: val.toFixed(2) }) - .where(eq(clients.id, clientId)); - - // Get all projects for this client, then update their payment stubs - const projectRows = await db + const asProject = await db .select({ id: projects.id }) .from(projects) - .where(eq(projects.client_id, clientId)); + .where(eq(projects.id, id)) + .limit(1); - if (projectRows.length > 0) { - const projectIds = projectRows.map((p) => p.id); + if (asProject[0]) { + // Project context: update projects.accepted_total + this project's payment stubs + await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id)); const half = (val / 2).toFixed(2); - const paymentsRows = await db - .select() - .from(payments) - .where(inArray(payments.project_id, projectIds)); - for (const p of paymentsRows) { + const projectPayments = await db.select({ id: payments.id }) + .from(payments).where(eq(payments.project_id, id)); + for (const p of projectPayments) { await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); } + revalidatePath(`/admin/projects/${id}`); + } else { + // Client context: update clients.accepted_total + all project payment stubs + await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id)); + const projectRows = await db.select({ id: projects.id }) + .from(projects).where(eq(projects.client_id, id)); + if (projectRows.length > 0) { + const projectIds = projectRows.map((p) => p.id); + const half = (val / 2).toFixed(2); + const paymentsRows = await db.select() + .from(payments).where(inArray(payments.project_id, projectIds)); + for (const p of paymentsRows) { + await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); + } + } + revalidatePath(`/admin/clients/${id}`); } - - revalidatePath(`/admin/clients/${clientId}`); } // ── COMMENTS (admin reply) ──────────────────────────────────────────────────── -export async function postAdminComment(clientId: string, formData: FormData) { +export async function postAdminComment(id: string, formData: FormData) { const entity = formData.get("entity") as string; const body = (formData.get("body") as string)?.trim(); if (!body || !entity) throw new Error("Dati mancanti"); @@ -250,5 +246,6 @@ export async function postAdminComment(clientId: string, formData: FormData) { const allowedTypes = ["task", "deliverable"]; if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido"); await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); - revalidatePath(`/admin/clients/${clientId}`); + const { path } = await resolveEntity(id); + revalidatePath(path); } \ No newline at end of file diff --git a/src/app/admin/clients/[id]/quote-actions.ts b/src/app/admin/clients/[id]/quote-actions.ts index e620ee7..de5df3b 100644 --- a/src/app/admin/clients/[id]/quote-actions.ts +++ b/src/app/admin/clients/[id]/quote-actions.ts @@ -1,7 +1,7 @@ "use server"; // quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md) -// Only clients.accepted_total is visible to client-facing routes +// Only accepted_total is visible to client-facing routes import { db } from "@/db"; import { quote_items, clients, projects } from "@/db/schema"; @@ -16,14 +16,26 @@ async function requireAdmin() { if (!session) throw new Error("Non autorizzato"); } -async function getDefaultProjectId(clientId: string): Promise { - const rows = await db +// Works for both clientId and projectId inputs +async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> { + const asProject = await db .select({ id: projects.id }) .from(projects) - .where(eq(projects.client_id, clientId)) + .where(eq(projects.id, id)) + .limit(1); + + if (asProject[0]) { + return { projectId: id, path: `/admin/projects/${id}` }; + } + + const asClient = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.client_id, id)) .orderBy(asc(projects.created_at)) .limit(1); - return rows[0]?.id ?? null; + + return { projectId: asClient[0]?.id ?? null, path: `/admin/clients/${id}` }; } const quoteItemSchema = z.object({ @@ -33,7 +45,7 @@ const quoteItemSchema = z.object({ unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"), }); -export async function addQuoteItem(clientId: string, formData: FormData) { +export async function addQuoteItem(id: string, formData: FormData) { await requireAdmin(); const rawServiceId = formData.get("service_id") as string | null; @@ -49,12 +61,10 @@ export async function addQuoteItem(clientId: string, formData: FormData) { if (!parsed.success) throw new Error(parsed.error.issues[0].message); if (!parsed.data.service_id && !parsed.data.custom_label) { - throw new Error( - "Seleziona un servizio dal catalogo o inserisci il nome di una voce libera" - ); + throw new Error("Seleziona un servizio dal catalogo o inserisci il nome di una voce libera"); } - const projectId = await getDefaultProjectId(clientId); + const { projectId, path } = await resolveEntity(id); if (!projectId) throw new Error("Nessun progetto trovato per questo cliente"); const { service_id, custom_label, quantity, unit_price } = parsed.data; @@ -69,23 +79,33 @@ export async function addQuoteItem(clientId: string, formData: FormData) { subtotal, }); - revalidatePath(`/admin/clients/${clientId}`); + revalidatePath(path); } -export async function removeQuoteItem(quoteItemId: string, clientId: string) { +export async function removeQuoteItem(quoteItemId: string, id: string) { await requireAdmin(); await db.delete(quote_items).where(eq(quote_items.id, quoteItemId)); - revalidatePath(`/admin/clients/${clientId}`); + const { path } = await resolveEntity(id); + revalidatePath(path); } -export async function updateAcceptedTotal(clientId: string, formData: FormData) { +export async function updateAcceptedTotal(id: string, formData: FormData) { await requireAdmin(); const raw = (formData.get("accepted_total") as string)?.trim(); const val = parseFloat(raw); if (isNaN(val) || val < 0) throw new Error("Importo non valido"); - await db - .update(clients) - .set({ accepted_total: val.toFixed(2) }) - .where(eq(clients.id, clientId)); - revalidatePath(`/admin/clients/${clientId}`); + + const asProject = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, id)) + .limit(1); + + if (asProject[0]) { + await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id)); + revalidatePath(`/admin/projects/${id}`); + } else { + await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id)); + revalidatePath(`/admin/clients/${id}`); + } } \ No newline at end of file diff --git a/src/app/admin/impostazioni/page.tsx b/src/app/admin/impostazioni/page.tsx new file mode 100644 index 0000000..57aa4e8 --- /dev/null +++ b/src/app/admin/impostazioni/page.tsx @@ -0,0 +1,59 @@ +import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; + +export const revalidate = 0; + +export default async function ImpostazioniPage() { + const targetRate = await getTargetHourlyRate(); + + async function handleSave(fd: FormData) { + "use server"; + const raw = fd.get("target_hourly_rate"); + const val = parseFloat(String(raw ?? "")); + if (isNaN(val) || val < 0) return; + await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, val.toFixed(2)); + } + + return ( +
+

Impostazioni

+ +
+

Analytics Profittabilità

+ +
+
+ +

+ Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto. +

+
+ + + /h +
+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx new file mode 100644 index 0000000..15490e0 --- /dev/null +++ b/src/app/admin/projects/[id]/page.tsx @@ -0,0 +1,138 @@ +import { notFound } from "next/navigation"; +import { getProjectFullDetail } from "@/lib/admin-queries"; +import { getTargetHourlyRate } from "@/lib/settings"; +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 { QuoteTab } from "@/components/admin/tabs/QuoteTab"; +import { TimerTab } from "@/components/admin/tabs/TimerTab"; +import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; +import Link from "next/link"; + +export const revalidate = 0; + +export default async function ProjectDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const [detail, targetHourlyRate] = await Promise.all([ + getProjectFullDetail(id), + getTargetHourlyRate(), + ]); + + if (!detail) notFound(); + + const { + project, + phases, + payments, + documents, + notes, + comments, + quoteItems, + activeServices, + activeTimerEntryId, + activeTimerStartedAt, + totalTrackedSeconds, + } = detail; + + return ( +
+
+ + ← Progetti + +
+ +
+
+

{project.name}

+

+ + {project.client.name} + +

+
+
+ + + + Fasi & Task + Pagamenti + Documenti + Note + Commenti + Preventivo + Timer + + + + } + phases={phases} + clientId={id} + /> + + + + + + + + + + + +
+ {notes.length === 0 && ( +

Nessuna nota ancora.

+ )} + {notes.map((note) => ( +
+

{note.body}

+

+ {new Date(note.created_at).toLocaleDateString("it-IT")} +

+
+ ))} +
+
+ + + + + + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/timer-actions.ts b/src/app/admin/timer-actions.ts index 39fdcde..893ffbb 100644 --- a/src/app/admin/timer-actions.ts +++ b/src/app/admin/timer-actions.ts @@ -32,6 +32,8 @@ export async function startTimer(projectId: string): Promise<{ entryId: string } const id = nanoid(); await db.insert(time_entries).values({ id, project_id: projectId }); revalidatePath("/admin"); + revalidatePath("/admin/projects"); + revalidatePath(`/admin/projects/${projectId}`); return { entryId: id }; } @@ -64,4 +66,5 @@ export async function stopTimer(entryId: string): Promise { .where(eq(time_entries.id, entryId)); revalidatePath("/admin"); + revalidatePath("/admin/projects"); } \ No newline at end of file diff --git a/src/components/admin/ProfitabilityCard.tsx b/src/components/admin/ProfitabilityCard.tsx new file mode 100644 index 0000000..d1d1252 --- /dev/null +++ b/src/components/admin/ProfitabilityCard.tsx @@ -0,0 +1,75 @@ +type ProfitabilityCardProps = { + acceptedTotal: string; + totalTrackedSeconds: number; + targetHourlyRate: number; +}; + +export function ProfitabilityCard({ + acceptedTotal, + totalTrackedSeconds, + targetHourlyRate, +}: ProfitabilityCardProps) { + const hours = totalTrackedSeconds / 3600; + const accepted = parseFloat(acceptedTotal || "0"); + const realHourlyRate = hours > 0 ? accepted / hours : null; + const idealCost = targetHourlyRate * hours; + const delta = accepted - idealCost; + const deltaIsProfit = delta >= 0; + + return ( +
+

Profittabilità

+ +
+
+

Ore lavorate

+

{hours.toFixed(1)}h

+
+
+

Importo accettato

+

+ {accepted > 0 + ? `€${accepted.toLocaleString("it-IT", { minimumFractionDigits: 2 })}` + : "Non impostato"} +

+
+
+ +
+
+ €/h reale + + {realHourlyRate !== null ? `€${realHourlyRate.toFixed(2)}/h` : "—"} + +
+
+ €/h target + + €{targetHourlyRate.toFixed(2)}/h + +
+
+ + Costo ideale ({hours.toFixed(1)}h × €{targetHourlyRate}/h) + + €{idealCost.toFixed(2)} +
+
+ + {hours > 0 && accepted > 0 && ( +
+ Delta (guadagno/perdita) + + {deltaIsProfit ? "+" : ""}€{delta.toFixed(2)} + +
+ )} + + {hours === 0 && ( +

+ Avvia il timer per iniziare a tracciare le ore. +

+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/tabs/TimerTab.tsx b/src/components/admin/tabs/TimerTab.tsx new file mode 100644 index 0000000..ab62396 --- /dev/null +++ b/src/components/admin/tabs/TimerTab.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { TimerCell } from "@/components/admin/TimerCell"; +import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard"; + +type TimerTabProps = { + projectId: string; + acceptedTotal: string; + activeTimerEntryId: string | null; + activeTimerStartedAt: Date | null; + totalTrackedSeconds: number; + targetHourlyRate: number; +}; + +export function TimerTab({ + projectId, + acceptedTotal, + activeTimerEntryId, + activeTimerStartedAt, + totalTrackedSeconds, + targetHourlyRate, +}: TimerTabProps) { + return ( +
+
+

Timer

+ +
+ + +
+ ); +} \ No newline at end of file