feat(04-03): project workspace + timer analytics + settings page
- /admin/projects/[id]: full workspace with 7 tabs (Fasi, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer) - TimerTab: TimerCell + ProfitabilityCard with €/h real, ideal cost, delta - ProfitabilityCard: hours worked, €/h real vs target, profit/loss delta - /admin/impostazioni: target_hourly_rate setting (default 50€/h) - actions.ts: resolveEntity() helper — actions now work transparently with both clientId and projectId (same tab components reused) - quote-actions.ts: same resolveEntity pattern for addQuoteItem, removeQuoteItem, updateAcceptedTotal - timer-actions.ts: revalidate /admin/projects and /admin/projects/[id] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,31 @@ import {
|
|||||||
import { eq, asc, inArray } from "drizzle-orm";
|
import { eq, asc, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
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 ───────────────────────────────────────────────────────────────
|
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const clientSchema = z.object({
|
const clientSchema = z.object({
|
||||||
@@ -50,22 +75,12 @@ export async function setClientArchived(clientId: string, archived: boolean) {
|
|||||||
|
|
||||||
// ── PHASES ────────────────────────────────────────────────────────────────────
|
// ── PHASES ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getDefaultProjectId(clientId: string): Promise<string | null> {
|
export async function addPhase(id: string, formData: FormData) {
|
||||||
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) {
|
|
||||||
const title = (formData.get("title") as string)?.trim();
|
const title = (formData.get("title") as string)?.trim();
|
||||||
if (!title) throw new Error("Titolo fase richiesto");
|
if (!title) throw new Error("Titolo fase richiesto");
|
||||||
|
|
||||||
const projectId = await getDefaultProjectId(clientId);
|
const { projectId, path } = await resolveEntity(id);
|
||||||
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
if (!projectId) throw new Error("Nessun progetto trovato");
|
||||||
|
|
||||||
const existingPhases = await db
|
const existingPhases = await db
|
||||||
.select({ sort_order: phases.sort_order })
|
.select({ sort_order: phases.sort_order })
|
||||||
@@ -79,27 +94,20 @@ export async function addPhase(clientId: string, formData: FormData) {
|
|||||||
sort_order: maxOrder + 1,
|
sort_order: maxOrder + 1,
|
||||||
status: "upcoming",
|
status: "upcoming",
|
||||||
});
|
});
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePhaseStatus(
|
export async function updatePhaseStatus(phaseId: string, id: string, status: string) {
|
||||||
phaseId: string,
|
|
||||||
clientId: string,
|
|
||||||
status: string
|
|
||||||
) {
|
|
||||||
const allowed = ["upcoming", "active", "done"];
|
const allowed = ["upcoming", "active", "done"];
|
||||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||||
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
|
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function addTask(
|
export async function addTask(phaseId: string, id: string, formData: FormData) {
|
||||||
phaseId: string,
|
|
||||||
clientId: string,
|
|
||||||
formData: FormData
|
|
||||||
) {
|
|
||||||
const title = (formData.get("title") as string)?.trim();
|
const title = (formData.get("title") as string)?.trim();
|
||||||
if (!title) throw new Error("Titolo task richiesto");
|
if (!title) throw new Error("Titolo task richiesto");
|
||||||
|
|
||||||
@@ -116,35 +124,27 @@ export async function addTask(
|
|||||||
sort_order: maxOrder + 1,
|
sort_order: maxOrder + 1,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
});
|
});
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTaskStatus(
|
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
||||||
taskId: string,
|
|
||||||
clientId: string,
|
|
||||||
status: string
|
|
||||||
) {
|
|
||||||
const allowed = ["todo", "in_progress", "done"];
|
const allowed = ["todo", "in_progress", "done"];
|
||||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
|
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function addDeliverable(
|
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
|
||||||
taskId: string,
|
|
||||||
clientId: string,
|
|
||||||
formData: FormData
|
|
||||||
) {
|
|
||||||
const title = (formData.get("title") as string)?.trim();
|
const title = (formData.get("title") as string)?.trim();
|
||||||
const url = (formData.get("url") as string)?.trim() || null;
|
const url = (formData.get("url") as string)?.trim() || null;
|
||||||
if (!title) throw new Error("Titolo deliverable richiesto");
|
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" });
|
||||||
await db
|
const { path } = await resolveEntity(id);
|
||||||
.insert(deliverables)
|
revalidatePath(path);
|
||||||
.values({ task_id: taskId, title, url, status: "pending" });
|
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DOCUMENTS ─────────────────────────────────────────────────────────────────
|
// ── DOCUMENTS ─────────────────────────────────────────────────────────────────
|
||||||
@@ -154,94 +154,90 @@ const docSchema = z.object({
|
|||||||
url: z.string().url("URL non valido"),
|
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({
|
const parsed = docSchema.safeParse({
|
||||||
label: formData.get("label"),
|
label: formData.get("label"),
|
||||||
url: formData.get("url"),
|
url: formData.get("url"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
|
||||||
const projectId = await getDefaultProjectId(clientId);
|
const { projectId, path } = await resolveEntity(id);
|
||||||
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
if (!projectId) throw new Error("Nessun progetto trovato");
|
||||||
|
|
||||||
await db.insert(documents).values({ project_id: projectId, ...parsed.data });
|
await db.insert(documents).values({ project_id: projectId, ...parsed.data });
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDocument(
|
export async function updateDocument(documentId: string, id: string, formData: FormData) {
|
||||||
documentId: string,
|
|
||||||
clientId: string,
|
|
||||||
formData: FormData
|
|
||||||
) {
|
|
||||||
const parsed = docSchema.safeParse({
|
const parsed = docSchema.safeParse({
|
||||||
label: formData.get("label"),
|
label: formData.get("label"),
|
||||||
url: formData.get("url"),
|
url: formData.get("url"),
|
||||||
});
|
});
|
||||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
await db
|
await db.update(documents).set(parsed.data).where(eq(documents.id, documentId));
|
||||||
.update(documents)
|
const { path } = await resolveEntity(id);
|
||||||
.set(parsed.data)
|
revalidatePath(path);
|
||||||
.where(eq(documents.id, documentId));
|
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
await db.delete(documents).where(eq(documents.id, documentId));
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PAYMENTS ──────────────────────────────────────────────────────────────────
|
// ── PAYMENTS ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function updatePaymentStatus(
|
export async function updatePaymentStatus(paymentId: string, id: string, status: string) {
|
||||||
paymentId: string,
|
|
||||||
clientId: string,
|
|
||||||
status: string
|
|
||||||
) {
|
|
||||||
const allowed = ["da_saldare", "inviata", "saldato"];
|
const allowed = ["da_saldare", "inviata", "saldato"];
|
||||||
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
||||||
const paid_at = status === "saldato" ? new Date() : null;
|
const paid_at = status === "saldato" ? new Date() : null;
|
||||||
await db
|
await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId));
|
||||||
.update(payments)
|
const { path } = await resolveEntity(id);
|
||||||
.set({ status, paid_at })
|
revalidatePath(path);
|
||||||
.where(eq(payments.id, paymentId));
|
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 raw = (formData.get("accepted_total") as string)?.trim();
|
||||||
const val = parseFloat(raw);
|
const val = parseFloat(raw);
|
||||||
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||||
|
|
||||||
// Update accepted_total on client row — denormalized field, quote_items never exposed
|
const asProject = await db
|
||||||
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
|
|
||||||
.select({ id: projects.id })
|
.select({ id: projects.id })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.client_id, clientId));
|
.where(eq(projects.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (projectRows.length > 0) {
|
if (asProject[0]) {
|
||||||
const projectIds = projectRows.map((p) => p.id);
|
// 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 half = (val / 2).toFixed(2);
|
||||||
const paymentsRows = await db
|
const projectPayments = await db.select({ id: payments.id })
|
||||||
.select()
|
.from(payments).where(eq(payments.project_id, id));
|
||||||
.from(payments)
|
for (const p of projectPayments) {
|
||||||
.where(inArray(payments.project_id, projectIds));
|
|
||||||
for (const p of paymentsRows) {
|
|
||||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
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) ────────────────────────────────────────────────────
|
// ── 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 entity = formData.get("entity") as string;
|
||||||
const body = (formData.get("body") as string)?.trim();
|
const body = (formData.get("body") as string)?.trim();
|
||||||
if (!body || !entity) throw new Error("Dati mancanti");
|
if (!body || !entity) throw new Error("Dati mancanti");
|
||||||
@@ -250,5 +246,6 @@ export async function postAdminComment(clientId: string, formData: FormData) {
|
|||||||
const allowedTypes = ["task", "deliverable"];
|
const allowedTypes = ["task", "deliverable"];
|
||||||
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
||||||
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
|
// 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 { db } from "@/db";
|
||||||
import { quote_items, clients, projects } from "@/db/schema";
|
import { quote_items, clients, projects } from "@/db/schema";
|
||||||
@@ -16,14 +16,26 @@ async function requireAdmin() {
|
|||||||
if (!session) throw new Error("Non autorizzato");
|
if (!session) throw new Error("Non autorizzato");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDefaultProjectId(clientId: string): Promise<string | null> {
|
// Works for both clientId and projectId inputs
|
||||||
const rows = await db
|
async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> {
|
||||||
|
const asProject = await db
|
||||||
.select({ id: projects.id })
|
.select({ id: projects.id })
|
||||||
.from(projects)
|
.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))
|
.orderBy(asc(projects.created_at))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return rows[0]?.id ?? null;
|
|
||||||
|
return { projectId: asClient[0]?.id ?? null, path: `/admin/clients/${id}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoteItemSchema = z.object({
|
const quoteItemSchema = z.object({
|
||||||
@@ -33,7 +45,7 @@ const quoteItemSchema = z.object({
|
|||||||
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"),
|
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();
|
await requireAdmin();
|
||||||
|
|
||||||
const rawServiceId = formData.get("service_id") as string | null;
|
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.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
|
||||||
if (!parsed.data.service_id && !parsed.data.custom_label) {
|
if (!parsed.data.service_id && !parsed.data.custom_label) {
|
||||||
throw new Error(
|
throw new Error("Seleziona un servizio dal catalogo o inserisci il nome di una voce libera");
|
||||||
"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");
|
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
||||||
|
|
||||||
const { service_id, custom_label, quantity, unit_price } = parsed.data;
|
const { service_id, custom_label, quantity, unit_price } = parsed.data;
|
||||||
@@ -69,23 +79,33 @@ export async function addQuoteItem(clientId: string, formData: FormData) {
|
|||||||
subtotal,
|
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 requireAdmin();
|
||||||
await db.delete(quote_items).where(eq(quote_items.id, quoteItemId));
|
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();
|
await requireAdmin();
|
||||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||||
const val = parseFloat(raw);
|
const val = parseFloat(raw);
|
||||||
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||||
await db
|
|
||||||
.update(clients)
|
const asProject = await db
|
||||||
.set({ accepted_total: val.toFixed(2) })
|
.select({ id: projects.id })
|
||||||
.where(eq(clients.id, clientId));
|
.from(projects)
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
.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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
|
||||||
|
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
|
||||||
|
|
||||||
|
<form action={handleSave} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="target_hourly_rate"
|
||||||
|
className="block text-sm font-medium text-[#1a1a1a] mb-1"
|
||||||
|
>
|
||||||
|
Tariffa oraria target (€/h)
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[#71717a] mb-2">
|
||||||
|
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-[#71717a]">€</span>
|
||||||
|
<input
|
||||||
|
id="target_hourly_rate"
|
||||||
|
name="target_hourly_rate"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={targetRate.toFixed(2)}
|
||||||
|
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#71717a]">/h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
|
||||||
|
>
|
||||||
|
Salva
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link href="/admin/projects" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
|
||||||
|
← Progetti
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">{project.name}</h1>
|
||||||
|
<p className="text-sm text-[#71717a]">
|
||||||
|
<Link
|
||||||
|
href={`/admin/clients/${project.client.id}`}
|
||||||
|
className="hover:text-[#1a1a1a] hover:underline"
|
||||||
|
>
|
||||||
|
{project.client.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="quote">Preventivo</TabsTrigger>
|
||||||
|
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="phases">
|
||||||
|
<PhasesViewToggle
|
||||||
|
listView={<PhasesTab phases={phases} clientId={id} />}
|
||||||
|
phases={phases}
|
||||||
|
clientId={id}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="payments">
|
||||||
|
<PaymentsTab
|
||||||
|
payments={payments}
|
||||||
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
|
clientId={id}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="documents">
|
||||||
|
<DocumentsTab documents={documents} clientId={id} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notes">
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="text-sm text-[#71717a]">Nessuna nota ancora.</p>
|
||||||
|
)}
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div key={note.id} className="bg-white rounded-lg border border-[#e5e7eb] p-4">
|
||||||
|
<p className="text-sm text-[#1a1a1a] whitespace-pre-wrap">{note.body}</p>
|
||||||
|
<p className="text-xs text-[#71717a] mt-2">
|
||||||
|
{new Date(note.created_at).toLocaleDateString("it-IT")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="comments">
|
||||||
|
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="quote">
|
||||||
|
<QuoteTab
|
||||||
|
clientId={id}
|
||||||
|
items={quoteItems}
|
||||||
|
activeServices={activeServices}
|
||||||
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="timer">
|
||||||
|
<TimerTab
|
||||||
|
projectId={id}
|
||||||
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
|
activeTimerEntryId={activeTimerEntryId}
|
||||||
|
activeTimerStartedAt={activeTimerStartedAt}
|
||||||
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
targetHourlyRate={targetHourlyRate}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ 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 });
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
return { entryId: id };
|
return { entryId: id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,4 +66,5 @@ export async function stopTimer(entryId: string): Promise<void> {
|
|||||||
.where(eq(time_entries.id, entryId));
|
.where(eq(time_entries.id, entryId));
|
||||||
|
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
type ProfitabilityCardProps = {
|
||||||
|
acceptedTotal: string;
|
||||||
|
totalTrackedSeconds: number;
|
||||||
|
targetHourlyRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfitabilityCard({
|
||||||
|
acceptedTotal,
|
||||||
|
totalTrackedSeconds,
|
||||||
|
targetHourlyRate,
|
||||||
|
}: ProfitabilityCardProps) {
|
||||||
|
const hours = totalTrackedSeconds / 3600;
|
||||||
|
const accepted = parseFloat(acceptedTotal || "0");
|
||||||
|
const realHourlyRate = hours > 0 ? accepted / hours : null;
|
||||||
|
const idealCost = targetHourlyRate * hours;
|
||||||
|
const delta = accepted - idealCost;
|
||||||
|
const deltaIsProfit = delta >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3">
|
||||||
|
<h3 className="font-medium text-[#1a1a1a]">Profittabilità</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[#71717a] text-xs">Ore lavorate</p>
|
||||||
|
<p className="font-mono font-semibold text-[#1a1a1a]">{hours.toFixed(1)}h</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[#71717a] text-xs">Importo accettato</p>
|
||||||
|
<p className="font-mono font-semibold text-[#1a1a1a]">
|
||||||
|
{accepted > 0
|
||||||
|
? `€${accepted.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`
|
||||||
|
: "Non impostato"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#f4f4f5] pt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#71717a]">€/h reale</span>
|
||||||
|
<span className="font-mono font-semibold text-[#1a1a1a]">
|
||||||
|
{realHourlyRate !== null ? `€${realHourlyRate.toFixed(2)}/h` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#71717a]">€/h target</span>
|
||||||
|
<span className="font-mono font-semibold text-[#71717a]">
|
||||||
|
€{targetHourlyRate.toFixed(2)}/h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#71717a]">
|
||||||
|
Costo ideale ({hours.toFixed(1)}h × €{targetHourlyRate}/h)
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-semibold text-[#1a1a1a]">€{idealCost.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hours > 0 && accepted > 0 && (
|
||||||
|
<div className="border-t border-[#f4f4f5] pt-3 flex justify-between items-center">
|
||||||
|
<span className="text-[#71717a]">Delta (guadagno/perdita)</span>
|
||||||
|
<span className={`font-mono font-bold ${deltaIsProfit ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{deltaIsProfit ? "+" : ""}€{delta.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hours === 0 && (
|
||||||
|
<p className="text-xs text-[#71717a] border-t border-[#f4f4f5] pt-3">
|
||||||
|
Avvia il timer per iniziare a tracciare le ore.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TimerCell } from "@/components/admin/TimerCell";
|
||||||
|
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
|
||||||
|
|
||||||
|
type TimerTabProps = {
|
||||||
|
projectId: string;
|
||||||
|
acceptedTotal: string;
|
||||||
|
activeTimerEntryId: string | null;
|
||||||
|
activeTimerStartedAt: Date | null;
|
||||||
|
totalTrackedSeconds: number;
|
||||||
|
targetHourlyRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimerTab({
|
||||||
|
projectId,
|
||||||
|
acceptedTotal,
|
||||||
|
activeTimerEntryId,
|
||||||
|
activeTimerStartedAt,
|
||||||
|
totalTrackedSeconds,
|
||||||
|
targetHourlyRate,
|
||||||
|
}: TimerTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-sm">
|
||||||
|
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
|
||||||
|
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
|
||||||
|
<TimerCell
|
||||||
|
clientId={projectId}
|
||||||
|
activeEntryId={activeTimerEntryId}
|
||||||
|
activeStartedAt={activeTimerStartedAt}
|
||||||
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfitabilityCard
|
||||||
|
acceptedTotal={acceptedTotal}
|
||||||
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
targetHourlyRate={targetHourlyRate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user