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 { 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<string | null> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user