From 63c9f750dfda5a841913dd8de1c4f985ac94fdf4 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 21 May 2026 21:58:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(04-01):=20multi-project=20schema=20migrati?= =?UTF-8?q?on=20=E2=80=94=20projects,=20settings,=20FK=20pivot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.ts: add projects table, settings kv table, slug on clients; migrate 6 FK from client_id to project_id (phases, payments, documents, notes, time_entries, quote_items); update all relations and TypeScript types - admin-queries.ts: fix getAllClientsWithPayments + getClientFullDetail to aggregate through projects; add getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects, ClientWithProjects type - settings.ts: new file — getSetting, updateSetting, getTargetHourlyRate, SETTINGS_KEYS - Fix all downstream files: actions.ts, quote-actions.ts, new/actions.ts, timer-actions.ts, approve/route.ts, comment/route.ts, TimerCell.tsx, analytics-queries.ts, client-view.ts, seed.ts - DB migration applied to Coolify Postgres (all test data cleared, schema rebuilt) Co-Authored-By: Claude Sonnet 4.6 --- scripts/seed.ts | 33 +- src/app/admin/clients/[id]/actions.ts | 53 ++- src/app/admin/clients/[id]/quote-actions.ts | 22 +- src/app/admin/clients/new/actions.ts | 21 +- src/app/admin/layout.tsx | 7 +- src/app/admin/timer-actions.ts | 22 +- src/app/api/client/approve/route.ts | 7 +- src/app/api/client/comment/route.ts | 62 ++-- src/components/admin/TimerCell.tsx | 4 +- src/db/schema.ts | 89 +++-- src/lib/admin-queries.ts | 392 ++++++++++++++++++-- src/lib/analytics-queries.ts | 34 +- src/lib/client-view.ts | 48 ++- src/lib/settings.ts | 35 ++ 14 files changed, 673 insertions(+), 156 deletions(-) create mode 100644 src/lib/settings.ts diff --git a/scripts/seed.ts b/scripts/seed.ts index a0f3cf2..7970146 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,6 +1,7 @@ import { db } from '@/db'; import { clients, + projects, phases, tasks, deliverables, @@ -29,12 +30,24 @@ async function seed() { console.log(`✓ Client created: ${client.name} (ID: ${client.id})`); + // Create a default project for the client (all work items are project-scoped) + const [project] = await db + .insert(projects) + .values({ + client_id: client.id, + name: client.brand_name, + accepted_total: '5000.00', + }) + .returning(); + + console.log(`✓ Project created: ${project.name} (ID: ${project.id})`); + const [phase1, phase2, phase3] = await db .insert(phases) .values([ - { client_id: client.id, title: 'Discovery & Strategy', sort_order: 1, status: 'done' }, - { client_id: client.id, title: 'Design & Messaging', sort_order: 2, status: 'active' }, - { client_id: client.id, title: 'Implementation & Launch', sort_order: 3, status: 'upcoming' }, + { project_id: project.id, title: 'Discovery & Strategy', sort_order: 1, status: 'done' }, + { project_id: project.id, title: 'Design & Messaging', sort_order: 2, status: 'active' }, + { project_id: project.id, title: 'Implementation & Launch', sort_order: 3, status: 'upcoming' }, ]) .returning(); @@ -68,8 +81,8 @@ async function seed() { await db .insert(payments) .values([ - { client_id: client.id, label: 'Acconto 50%', amount: '2500.00', status: 'saldato', paid_at: new Date('2026-04-01') }, - { client_id: client.id, label: 'Saldo 50%', amount: '2500.00', status: 'inviata', paid_at: null }, + { project_id: project.id, label: 'Acconto 50%', amount: '2500.00', status: 'saldato', paid_at: new Date('2026-04-01') }, + { project_id: project.id, label: 'Saldo 50%', amount: '2500.00', status: 'inviata', paid_at: null }, ]); console.log('✓ Payments created (2 total)'); @@ -77,8 +90,8 @@ async function seed() { await db .insert(documents) .values([ - { client_id: client.id, label: 'Brand Guidelines PDF', url: 'https://example.com/brand-guidelines.pdf' }, - { client_id: client.id, label: 'Design Mockups Figma', url: 'https://www.figma.com/file/example' }, + { project_id: project.id, label: 'Brand Guidelines PDF', url: 'https://example.com/brand-guidelines.pdf' }, + { project_id: project.id, label: 'Design Mockups Figma', url: 'https://www.figma.com/file/example' }, ]); console.log('✓ Documents created (2 total)'); @@ -86,15 +99,15 @@ async function seed() { await db .insert(notes) .values([ - { client_id: client.id, body: 'Initial strategy session completed. Key insight: positioning needs to emphasize tech expertise and creative thinking balance.', created_at: new Date('2026-04-10') }, - { client_id: client.id, body: 'Phase 1 approved. Moving forward with design phase. Stakeholders excited about direction.', created_at: new Date('2026-04-22') }, + { project_id: project.id, body: 'Initial strategy session completed. Key insight: positioning needs to emphasize tech expertise and creative thinking balance.', created_at: new Date('2026-04-10') }, + { project_id: project.id, body: 'Phase 1 approved. Moving forward with design phase. Stakeholders excited about direction.', created_at: new Date('2026-04-22') }, ]); console.log('✓ Notes created (2 total)'); console.log('\n✨ Seed complete!\n'); console.log('📎 Shareable client link:'); - console.log(` http://localhost:3000/c/${clientToken}\n`); + console.log(` http://localhost:3000/client/${clientToken}\n`); console.log('This link is unique and secret. Send it to the client via Slack or email.\n'); process.exit(0); diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index e665c36..4ae1ad5 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -10,9 +10,10 @@ import { documents, payments, clients, + projects, comments, } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { eq, asc, inArray } from "drizzle-orm"; import { z } from "zod"; // ── CLIENT CRUD ─────────────────────────────────────────────────────────────── @@ -49,18 +50,31 @@ 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) { 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 existingPhases = await db .select({ sort_order: phases.sort_order }) .from(phases) - .where(eq(phases.client_id, clientId)); + .where(eq(phases.project_id, projectId)); const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1); await db.insert(phases).values({ - client_id: clientId, + project_id: projectId, title, sort_order: maxOrder + 1, status: "upcoming", @@ -146,7 +160,11 @@ export async function addDocument(clientId: string, formData: FormData) { url: formData.get("url"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); - await db.insert(documents).values({ client_id: clientId, ...parsed.data }); + + const projectId = await getDefaultProjectId(clientId); + if (!projectId) throw new Error("Nessun progetto trovato per questo cliente"); + + await db.insert(documents).values({ project_id: projectId, ...parsed.data }); revalidatePath(`/admin/clients/${clientId}`); } @@ -193,20 +211,31 @@ export async function updateAcceptedTotal(clientId: 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)); - // Split evenly between two payment rows (Acconto 50% + Saldo 50%) - const half = (val / 2).toFixed(2); - const paymentsRows = await db - .select() - .from(payments) - .where(eq(payments.client_id, clientId)); - for (const p of paymentsRows) { - await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); + + // Get all projects for this client, then update their payment stubs + const projectRows = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.client_id, clientId)); + + 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/${clientId}`); } diff --git a/src/app/admin/clients/[id]/quote-actions.ts b/src/app/admin/clients/[id]/quote-actions.ts index f76391d..e620ee7 100644 --- a/src/app/admin/clients/[id]/quote-actions.ts +++ b/src/app/admin/clients/[id]/quote-actions.ts @@ -4,9 +4,9 @@ // Only clients.accepted_total is visible to client-facing routes import { db } from "@/db"; -import { quote_items, clients, service_catalog } from "@/db/schema"; +import { quote_items, clients, projects } from "@/db/schema"; import { revalidatePath } from "next/cache"; -import { eq } from "drizzle-orm"; +import { eq, asc } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; @@ -16,6 +16,16 @@ async function requireAdmin() { if (!session) throw new Error("Non autorizzato"); } +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; +} + const quoteItemSchema = z.object({ service_id: z.string().nullable(), custom_label: z.string().nullable(), @@ -38,18 +48,20 @@ export async function addQuoteItem(clientId: string, formData: FormData) { if (!parsed.success) throw new Error(parsed.error.issues[0].message); - // Validate: either service_id or custom_label must be present 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" ); } + const projectId = await getDefaultProjectId(clientId); + if (!projectId) throw new Error("Nessun progetto trovato per questo cliente"); + const { service_id, custom_label, quantity, unit_price } = parsed.data; const subtotal = (quantity * unit_price).toFixed(2); await db.insert(quote_items).values({ - client_id: clientId, + project_id: projectId, service_id: service_id ?? null, custom_label: custom_label ?? null, quantity: quantity.toFixed(2), @@ -76,4 +88,4 @@ export async function updateAcceptedTotal(clientId: string, formData: FormData) .set({ accepted_total: val.toFixed(2) }) .where(eq(clients.id, clientId)); revalidatePath(`/admin/clients/${clientId}`); -} +} \ No newline at end of file diff --git a/src/app/admin/clients/new/actions.ts b/src/app/admin/clients/new/actions.ts index f9cacf2..1418d65 100644 --- a/src/app/admin/clients/new/actions.ts +++ b/src/app/admin/clients/new/actions.ts @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/db"; -import { clients, payments } from "@/db/schema"; +import { clients, projects, payments } from "@/db/schema"; const createClientSchema = z.object({ name: z.string().min(1, "Nome richiesto"), @@ -21,7 +21,6 @@ export async function createClient(formData: FormData) { const parsed = createClientSchema.safeParse(raw); if (!parsed.success) { - // In v1 return errors as thrown string — form displays validation inline throw new Error( parsed.error.issues.map((i) => i.message).join(", ") ); @@ -35,19 +34,27 @@ export async function createClient(formData: FormData) { brand_name: parsed.data.brand_name, brief: parsed.data.brief, }) - .returning({ id: clients.id, token: clients.token }); + .returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name }); - // Always create two payment stubs per client — Acconto 50% and Saldo 50% - // Amounts default to 0 until admin sets accepted_total; admin updates separately + // Create a default project for the client — all work items are project-scoped + const [newProject] = await db + .insert(projects) + .values({ + client_id: newClient.id, + name: newClient.brand_name, + }) + .returning({ id: projects.id }); + + // Always create two payment stubs per project — Acconto 50% and Saldo 50% await db.insert(payments).values([ { - client_id: newClient.id, + project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare", }, { - client_id: newClient.id, + project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare", diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 7a4eb38..2a16a8a 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,13 +1,16 @@ import { NavBar } from "@/components/admin/NavBar"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; -export default function AdminLayout({ +export default async function AdminLayout({ children, }: { children: React.ReactNode; }) { + const session = await getServerSession(authOptions); return (
- + {session && }
{children}
); diff --git a/src/app/admin/timer-actions.ts b/src/app/admin/timer-actions.ts index b8e37b7..39fdcde 100644 --- a/src/app/admin/timer-actions.ts +++ b/src/app/admin/timer-actions.ts @@ -2,12 +2,12 @@ import { revalidatePath } from "next/cache"; import { db } from "@/db"; -import { time_entries } from "@/db/schema"; -import { eq, isNull } from "drizzle-orm"; +import { time_entries, projects } from "@/db/schema"; +import { eq, isNull, asc } from "drizzle-orm"; import { nanoid } from "nanoid"; -export async function startTimer(clientId: string): Promise<{ entryId: string }> { - // Stop any currently running session (for any client) before starting a new one +export async function startTimer(projectId: string): Promise<{ entryId: string }> { + // Stop any currently running session before starting a new one const running = await db .select({ id: time_entries.id }) .from(time_entries) @@ -30,11 +30,23 @@ export async function startTimer(clientId: string): Promise<{ entryId: string }> } const id = nanoid(); - await db.insert(time_entries).values({ id, client_id: clientId }); + await db.insert(time_entries).values({ id, project_id: projectId }); revalidatePath("/admin"); return { entryId: id }; } +export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> { + 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 { const rows = await db .select({ started_at: time_entries.started_at }) diff --git a/src/app/api/client/approve/route.ts b/src/app/api/client/approve/route.ts index 10d941b..3f7a5eb 100644 --- a/src/app/api/client/approve/route.ts +++ b/src/app/api/client/approve/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { eq, and } from "drizzle-orm"; import { db } from "@/db"; -import { clients, deliverables, tasks, phases } from "@/db/schema"; +import { clients, deliverables, tasks, phases, projects } from "@/db/schema"; export async function POST(request: NextRequest) { try { @@ -26,12 +26,13 @@ export async function POST(request: NextRequest) { const clientId = clientRows[0].id; // Verify deliverable belongs to this client (prevents cross-client approval) - // deliverable → task → phase → client + // deliverable → task → phase → project → client const ownershipCheck = await db .select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at }) .from(deliverables) .innerJoin(tasks, eq(deliverables.task_id, tasks.id)) - .innerJoin(phases, and(eq(tasks.phase_id, phases.id), eq(phases.client_id, clientId))) + .innerJoin(phases, eq(tasks.phase_id, phases.id)) + .innerJoin(projects, and(eq(phases.project_id, projects.id), eq(projects.client_id, clientId))) .where(eq(deliverables.id, deliverableId)) .limit(1); diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts index cdbd66c..c376b3f 100644 --- a/src/app/api/client/comment/route.ts +++ b/src/app/api/client/comment/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; -import { clients, comments, tasks, phases, deliverables } from "@/db/schema"; +import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema"; const commentSchema = z.object({ token: z.string().min(1), @@ -43,46 +43,50 @@ export async function POST(request: NextRequest) { if (entity_id !== clientId) { return NextResponse.json({ error: "Entity non valida" }, { status: 403 }); } - } else if (entity_type === "task") { - const phasesForClient = await db - .select({ id: phases.id }) - .from(phases) - .where(eq(phases.client_id, clientId)); - const phaseIds = phasesForClient.map((p) => p.id); - if (phaseIds.length === 0) { - return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); - } - const taskRows = await db - .select({ id: tasks.id }) - .from(tasks) - .where(inArray(tasks.phase_id, phaseIds)); - if (!taskRows.find((r) => r.id === entity_id)) { - return NextResponse.json({ error: "Task non trovato" }, { status: 404 }); - } } else { - // deliverable + // Scope phases through projects → client + const clientProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.client_id, clientId)); + const projectIds = clientProjects.map((p) => p.id); + + if (projectIds.length === 0) { + return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); + } + const phasesForClient = await db .select({ id: phases.id }) .from(phases) - .where(eq(phases.client_id, clientId)); + .where(inArray(phases.project_id, projectIds)); const phaseIds = phasesForClient.map((p) => p.id); + if (phaseIds.length === 0) { return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); } + const taskRows = await db .select({ id: tasks.id }) .from(tasks) .where(inArray(tasks.phase_id, phaseIds)); - const taskIds = taskRows.map((r) => r.id); - if (taskIds.length === 0) { - return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 }); - } - const delivRows = await db - .select({ id: deliverables.id }) - .from(deliverables) - .where(inArray(deliverables.task_id, taskIds)); - if (!delivRows.find((r) => r.id === entity_id)) { - return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 }); + + if (entity_type === "task") { + if (!taskRows.find((r) => r.id === entity_id)) { + return NextResponse.json({ error: "Task non trovato" }, { status: 404 }); + } + } else { + // deliverable + const taskIds = taskRows.map((r) => r.id); + if (taskIds.length === 0) { + return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 }); + } + const delivRows = await db + .select({ id: deliverables.id }) + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + if (!delivRows.find((r) => r.id === entity_id)) { + return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 }); + } } } diff --git a/src/components/admin/TimerCell.tsx b/src/components/admin/TimerCell.tsx index f3534aa..733c9cb 100644 --- a/src/components/admin/TimerCell.tsx +++ b/src/components/admin/TimerCell.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { startTimer, stopTimer } from "@/app/admin/timer-actions"; +import { startTimerForClient, stopTimer } from "@/app/admin/timer-actions"; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -47,7 +47,7 @@ export function TimerCell({ if (isRunning && activeEntryId) { await stopTimer(activeEntryId); } else { - await startTimer(clientId); + await startTimerForClient(clientId); } router.refresh(); }); diff --git a/src/db/schema.ts b/src/db/schema.ts index bf42a6f..f9d9de2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -22,7 +22,9 @@ export const clients = pgTable("clients", { .notNull() .unique() .$defaultFn(() => nanoid()), - // accepted_total is DENORMALIZED — client API never exposes quote_items + // slug è opzionale, univoco, URL-safe (es. mario-rossi) — se assente, il link usa il token + slug: text("slug").unique(), + // accepted_total rimane per compatibilità — il valore authoritative si sposta su projects accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default( "0" ), @@ -32,14 +34,28 @@ export const clients = pgTable("clients", { .defaultNow(), }); -// ============ PHASES ============ -export const phases = pgTable("phases", { +// ============ PROJECTS ============ +export const projects = pgTable("projects", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), client_id: text("client_id") .notNull() .references(() => clients.id, { onDelete: "cascade" }), + name: text("name").notNull(), // brand/project name + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), + archived: boolean("archived").notNull().default(false), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// ============ PHASES ============ +export const phases = pgTable("phases", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + project_id: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), title: text("title").notNull(), sort_order: integer("sort_order").notNull().default(0), status: text("status").notNull().default("upcoming"), // upcoming | active | done @@ -93,9 +109,9 @@ export const payments = pgTable("payments", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), - client_id: text("client_id") + project_id: text("project_id") .notNull() - .references(() => clients.id, { onDelete: "cascade" }), + .references(() => projects.id, { onDelete: "cascade" }), label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%" amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato @@ -107,9 +123,9 @@ export const documents = pgTable("documents", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), - client_id: text("client_id") + project_id: text("project_id") .notNull() - .references(() => clients.id, { onDelete: "cascade" }), + .references(() => projects.id, { onDelete: "cascade" }), label: text("label").notNull(), url: text("url").notNull(), // external URL only — no file hosting in v1 created_at: timestamp("created_at", { withTimezone: true }) @@ -122,23 +138,23 @@ export const notes = pgTable("notes", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), - client_id: text("client_id") + project_id: text("project_id") .notNull() - .references(() => clients.id, { onDelete: "cascade" }), + .references(() => projects.id, { onDelete: "cascade" }), body: text("body").notNull(), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); -// ============ TIME ENTRIES (admin time tracking per client) ============ +// ============ TIME ENTRIES (admin time tracking per project) ============ export const time_entries = pgTable("time_entries", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), - client_id: text("client_id") + project_id: text("project_id") .notNull() - .references(() => clients.id, { onDelete: "cascade" }), + .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 @@ -160,9 +176,9 @@ export const quote_items = pgTable("quote_items", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), - client_id: text("client_id") + project_id: text("project_id") .notNull() - .references(() => clients.id, { onDelete: "cascade" }), + .references(() => projects.id, { onDelete: "cascade" }), service_id: text("service_id") .references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), @@ -171,18 +187,31 @@ export const quote_items = pgTable("quote_items", { custom_label: text("custom_label"), // free-form item label (when service_id is null) }); +// ============ SETTINGS (global admin settings — key-value store) ============ +export const settings = pgTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + // ============ RELATIONS ============ export const clientsRelations = relations(clients, ({ many }) => ({ + projects: many(projects), +})); + +export const projectsRelations = relations(projects, ({ one, many }) => ({ + client: one(clients, { fields: [projects.client_id], references: [clients.id] }), phases: many(phases), payments: many(payments), documents: many(documents), notes: many(notes), quote_items: many(quote_items), + time_entries: many(time_entries), })); export const phasesRelations = relations(phases, ({ one, many }) => ({ - client: one(clients, { fields: [phases.client_id], references: [clients.id] }), + project: one(projects, { fields: [phases.project_id], references: [projects.id] }), tasks: many(tasks), })); @@ -200,27 +229,31 @@ export const commentsRelations = relations(comments, (_) => ({ })); export const paymentsRelations = relations(payments, ({ one }) => ({ - client: one(clients, { - fields: [payments.client_id], - references: [clients.id], + project: one(projects, { + fields: [payments.project_id], + references: [projects.id], }), })); export const documentsRelations = relations(documents, ({ one }) => ({ - client: one(clients, { - fields: [documents.client_id], - references: [clients.id], + project: one(projects, { + fields: [documents.project_id], + references: [projects.id], }), })); export const notesRelations = relations(notes, ({ one }) => ({ - client: one(clients, { fields: [notes.client_id], references: [clients.id] }), + project: one(projects, { fields: [notes.project_id], references: [projects.id] }), +})); + +export const timeEntriesRelations = relations(time_entries, ({ one }) => ({ + project: one(projects, { fields: [time_entries.project_id], references: [projects.id] }), })); export const quoteItemsRelations = relations(quote_items, ({ one }) => ({ - client: one(clients, { - fields: [quote_items.client_id], - references: [clients.id], + project: one(projects, { + fields: [quote_items.project_id], + references: [projects.id], }), service: one(service_catalog, { fields: [quote_items.service_id], @@ -239,6 +272,8 @@ export const serviceCatalogRelations = relations( export type Client = typeof clients.$inferSelect; export type NewClient = typeof clients.$inferInsert; +export type Project = typeof projects.$inferSelect; +export type NewProject = typeof projects.$inferInsert; export type Phase = typeof phases.$inferSelect; export type NewPhase = typeof phases.$inferInsert; export type Task = typeof tasks.$inferSelect; @@ -258,4 +293,6 @@ export type NewServiceCatalog = typeof service_catalog.$inferInsert; export type QuoteItem = typeof quote_items.$inferSelect; export type NewQuoteItem = typeof quote_items.$inferInsert; export type TimeEntry = typeof time_entries.$inferSelect; -export type NewTimeEntry = typeof time_entries.$inferInsert; \ No newline at end of file +export type NewTimeEntry = typeof time_entries.$inferInsert; +export type Setting = typeof settings.$inferSelect; +export type NewSetting = typeof settings.$inferInsert; \ No newline at end of file diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index a24d00f..e41bb0c 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -1,6 +1,7 @@ import { db } from "@/db"; import { clients, + projects, payments, phases, tasks, @@ -11,10 +12,12 @@ import { time_entries, quote_items, service_catalog, + settings, } from "@/db/schema"; -import { eq, inArray, asc, isNull, sql } from "drizzle-orm"; +import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; import type { Client, + Project, Phase, Task, Deliverable, @@ -25,6 +28,8 @@ import type { ServiceCatalog, } from "@/db/schema"; +// ── ClientWithPayments — used by /admin clients list ───────────────────────── + export type ClientWithPayments = { id: string; name: string; @@ -55,37 +60,79 @@ export async function getAllClientsWithPayments( const clientIds = visible.map((c) => c.id); - const [allPayments, activeEntries, totals] = await Promise.all([ - db.select().from(payments), + // Get all projects for these clients (payments/time_entries now scoped to project) + const clientProjects = await db + .select({ id: projects.id, client_id: projects.client_id }) + .from(projects) + .where(inArray(projects.client_id, clientIds)); + + const projectIds = clientProjects.map((p) => p.id); + const projectToClient = new Map(clientProjects.map((p) => [p.id, p.client_id])); + + if (projectIds.length === 0) { + return visible.map((c) => ({ + id: c.id, + name: c.name, + brand_name: c.brand_name, + token: c.token, + accepted_total: c.accepted_total ?? "0", + archived: c.archived ?? false, + created_at: c.created_at, + payments: [], + activeTimerEntryId: null, + activeTimerStartedAt: null, + totalTrackedSeconds: 0, + })); + } + + const [allPayments, activeEntries, totals] = await Promise.all([ + db.select().from(payments).where(inArray(payments.project_id, projectIds)), - // Running timer sessions (ended_at IS NULL) db .select({ id: time_entries.id, - client_id: time_entries.client_id, + project_id: time_entries.project_id, started_at: time_entries.started_at, }) .from(time_entries) .where(isNull(time_entries.ended_at)), - // Total tracked seconds per client (completed sessions) db .select({ - client_id: time_entries.client_id, + project_id: time_entries.project_id, total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, }) .from(time_entries) - .where(inArray(time_entries.client_id, clientIds)) - .groupBy(time_entries.client_id), + .where(inArray(time_entries.project_id, projectIds)) + .groupBy(time_entries.project_id), ]); - const totalMap = new Map(totals.map((r) => [r.client_id, parseInt(r.total)])); - const activeMap = new Map( - activeEntries.map((r) => [r.client_id, { id: r.id, started_at: r.started_at }]) - ); + // Aggregate project-scoped data back to client level + const clientPaymentsMap = new Map(); + for (const payment of allPayments) { + const clientId = projectToClient.get(payment.project_id); + if (!clientId) continue; + if (!clientPaymentsMap.has(clientId)) clientPaymentsMap.set(clientId, []); + clientPaymentsMap.get(clientId)!.push(payment); + } + + const clientTimerMap = new Map(); + for (const entry of activeEntries) { + const clientId = projectToClient.get(entry.project_id); + if (!clientId || clientTimerMap.has(clientId)) continue; + clientTimerMap.set(clientId, { id: entry.id, started_at: entry.started_at }); + } + + const clientTotalsMap = new Map(); + for (const row of totals) { + const clientId = projectToClient.get(row.project_id); + if (!clientId) continue; + clientTotalsMap.set(clientId, (clientTotalsMap.get(clientId) ?? 0) + parseInt(row.total)); + } return visible.map((c) => { - const active = activeMap.get(c.id); + const active = clientTimerMap.get(c.id); + const clientPayments = clientPaymentsMap.get(c.id) ?? []; return { id: c.id, name: c.name, @@ -94,12 +141,15 @@ export async function getAllClientsWithPayments( accepted_total: c.accepted_total ?? "0", archived: c.archived ?? false, created_at: c.created_at, - payments: allPayments - .filter((p) => p.client_id === c.id) - .map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })), + payments: clientPayments.map((p) => ({ + id: p.id, + label: p.label, + status: p.status, + amount: String(p.amount), + })), activeTimerEntryId: active?.id ?? null, activeTimerStartedAt: active?.started_at ?? null, - totalTrackedSeconds: totalMap.get(c.id) ?? 0, + totalTrackedSeconds: clientTotalsMap.get(c.id) ?? 0, }; }); } @@ -138,10 +188,36 @@ export async function getClientFullDetail(id: string): Promise p.id); + + const activeServiceRows = await db + .select() + .from(service_catalog) + .where(eq(service_catalog.active, true)) + .orderBy(asc(service_catalog.name)); + + if (projectIds.length === 0) { + return { + client, + phases: [], + payments: [], + documents: [], + notes: [], + comments: [], + quoteItems: [], + activeServices: activeServiceRows, + }; + } + const phasesRows = await db .select() .from(phases) - .where(eq(phases.client_id, id)) + .where(inArray(phases.project_id, projectIds)) .orderBy(asc(phases.sort_order)); const phaseIds = phasesRows.map((p) => p.id); @@ -162,18 +238,21 @@ export async function getClientFullDetail(id: string): Promise d.id)]; @@ -209,15 +288,9 @@ export async function getClientFullDetail(id: string): Promise { .select() .from(service_catalog) .orderBy(asc(service_catalog.name)); +} + +// ── ProjectWithPayments — used by /admin/projects list ─────────────────────── + +export type ProjectWithPayments = { + id: string; + name: string; + client: { id: string; name: string; slug: string | null }; + accepted_total: string; + archived: boolean; + created_at: Date; + payments: Array<{ id: string; label: string; status: string; amount: string }>; + activeTimerEntryId: string | null; + activeTimerStartedAt: Date | null; + totalTrackedSeconds: number; +}; + +export async function getAllProjectsWithPayments( + includeArchived = false +): Promise { + const allProjects = await db + .select({ + id: projects.id, + name: projects.name, + client_id: projects.client_id, + accepted_total: projects.accepted_total, + archived: projects.archived, + created_at: projects.created_at, + }) + .from(projects) + .orderBy(projects.created_at); + + const visible = includeArchived + ? allProjects + : allProjects.filter((p) => !p.archived); + + if (visible.length === 0) return []; + + 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([ + 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, + total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, + }) + .from(time_entries) + .where(inArray(time_entries.project_id, projectIds)) + .groupBy(time_entries.project_id), + + db + .select({ id: clients.id, name: clients.name, slug: clients.slug }) + .from(clients) + .where(inArray(clients.id, clientIds)), + ]); + + 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); + + return { + id: project.id, + name: project.name, + client: parentClient ?? { id: project.client_id, name: "—", slug: null }, + accepted_total: project.accepted_total ?? "0", + archived: project.archived, + created_at: project.created_at, + payments: projectPayments.map((p) => ({ + id: p.id, + label: p.label, + status: p.status, + amount: String(p.amount), + })), + activeTimerEntryId: activeEntry?.id ?? null, + activeTimerStartedAt: activeEntry?.started_at ?? null, + totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0, + }; + }); +} + +// ── ProjectFullDetail — used by /admin/projects/[id] workspace ─────────────── + +export type ProjectFullDetail = { + project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } }; + phases: Array }>; + payments: Payment[]; + documents: Document[]; + notes: Note[]; + comments: Comment[]; + quoteItems: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; + activeTimerEntryId: string | null; + activeTimerStartedAt: Date | null; + totalTrackedSeconds: number; +}; + +export async function getProjectFullDetail(id: string): Promise { + const projectRows = await db + .select() + .from(projects) + .where(eq(projects.id, id)) + .limit(1); + + if (projectRows.length === 0) return null; + const project = projectRows[0]; + + const clientRows = await db + .select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, slug: clients.slug }) + .from(clients) + .where(eq(clients.id, project.client_id)) + .limit(1); + const client = clientRows[0] ?? { id: project.client_id, name: "—", brand_name: "—", slug: null }; + + const phasesRows = await db + .select() + .from(phases) + .where(eq(phases.project_id, id)) + .orderBy(asc(phases.sort_order)); + + const phaseIds = phasesRows.map((p) => p.id); + + const tasksRows = + phaseIds.length === 0 + ? [] + : await db + .select() + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)) + .orderBy(asc(tasks.sort_order)); + + const taskIds = tasksRows.map((t) => t.id); + + const deliverablesRows = + taskIds.length === 0 + ? [] + : await db + .select() + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes] = + 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)), + db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)), + db + .select({ + id: quote_items.id, + label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, + custom_label: quote_items.custom_label, + service_id: quote_items.service_id, + quantity: quote_items.quantity, + unit_price: quote_items.unit_price, + subtotal: quote_items.subtotal, + }) + .from(quote_items) + .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) + .where(eq(quote_items.project_id, id)) + .orderBy(asc(quote_items.id)), + db.select().from(service_catalog).where(eq(service_catalog.active, true)).orderBy(asc(service_catalog.name)), + db + .select({ id: time_entries.id, started_at: time_entries.started_at }) + .from(time_entries) + .where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at))) + .limit(1), + db + .select({ total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)` }) + .from(time_entries) + .where(eq(time_entries.project_id, id)), + ]); + + 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 + .filter((t) => t.phase_id === phase.id) + .map((task) => ({ + ...task, + deliverables: deliverablesRows.filter((d) => d.task_id === task.id), + })), + })); + + return { + project: { ...project, client } as ProjectFullDetail["project"], + phases: phasesWithTasks, + 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, + totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0, + }; +} + +// ── ClientWithProjects — used by client detail showing project cards ────────── + +export type ClientWithProjects = Client & { + projects: Array<{ + id: string; + name: string; + accepted_total: string; + archived: boolean; + created_at: Date; + }>; +}; + +export async function getClientWithProjects(clientId: string): Promise { + const clientRows = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (clientRows.length === 0) return null; + const client = clientRows[0]; + + const projectRows = await db + .select() + .from(projects) + .where(eq(projects.client_id, clientId)) + .orderBy(asc(projects.created_at)); + + return { + ...client, + projects: projectRows.map((p) => ({ + id: p.id, + name: p.name, + accepted_total: p.accepted_total ?? "0", + archived: p.archived, + created_at: p.created_at, + })), + }; } \ No newline at end of file diff --git a/src/lib/analytics-queries.ts b/src/lib/analytics-queries.ts index f2d1f31..30ce7b1 100644 --- a/src/lib/analytics-queries.ts +++ b/src/lib/analytics-queries.ts @@ -1,6 +1,6 @@ import { db } from "@/db"; -import { clients, payments, time_entries } from "@/db/schema"; -import { sql, and, eq } from "drizzle-orm"; +import { clients, payments, time_entries, projects } from "@/db/schema"; +import { sql, and, eq, inArray } from "drizzle-orm"; export async function getAnalyticsByYear(year: number) { const [contracted] = await db @@ -81,7 +81,7 @@ export type ClientTimeRow = { export async function getTimeByClient(year: number): Promise { const rows = await db .select({ - client_id: time_entries.client_id, + project_id: time_entries.project_id, total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, }) .from(time_entries) @@ -89,18 +89,34 @@ export async function getTimeByClient(year: number): Promise { sql`${time_entries.ended_at} is not null and extract(year from ${time_entries.started_at}) = ${year}` ) - .groupBy(time_entries.client_id); + .groupBy(time_entries.project_id); if (rows.length === 0) return []; + // Map project_id → client_id + const projectIds = rows.map((r) => r.project_id); + const projectRows = await db + .select({ id: projects.id, client_id: projects.client_id }) + .from(projects) + .where(inArray(projects.id, projectIds)); + const projectToClient = new Map(projectRows.map((p) => [p.id, p.client_id])); + const allClients = await db.select({ id: clients.id, name: clients.name }).from(clients); const nameMap = new Map(allClients.map((c) => [c.id, c.name])); - return rows - .map((r) => ({ - clientId: r.client_id, - clientName: nameMap.get(r.client_id) ?? r.client_id, - totalSeconds: parseInt(r.total), + // Aggregate by client_id (a client may have multiple projects) + const clientTotals = new Map(); + for (const row of rows) { + const clientId = projectToClient.get(row.project_id); + if (!clientId) continue; + clientTotals.set(clientId, (clientTotals.get(clientId) ?? 0) + parseInt(row.total)); + } + + return [...clientTotals.entries()] + .map(([clientId, totalSeconds]) => ({ + clientId, + clientName: nameMap.get(clientId) ?? clientId, + totalSeconds, })) .sort((a, b) => b.totalSeconds - a.totalSeconds); } diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index 39c7356..eb08176 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -1,6 +1,6 @@ import { eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; -import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema'; +import { clients, projects, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema'; /** * ClientView: The ONLY data shape returned to client-facing routes. @@ -61,7 +61,7 @@ export interface ClientView { /** * getClientView: Fetch all client data and return only the ClientView shape. * NEVER queries quote_items, service_catalog, or service prices. - * Uses inArray() to scope tasks/deliverables to this client's phases only. + * Aggregates data across all projects for this client. */ export async function getClientView(token: string): Promise { // Fetch client by token (Architecture constraint: token is separate from id PK) @@ -77,14 +77,37 @@ export async function getClientView(token: string): Promise { const client = clientRow[0]; - // Fetch all phases for this client, ordered by sort_order + // Get all projects for this client (data is now project-scoped) + const projectRows = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.client_id, client.id)); + const projectIds = projectRows.map((p) => p.id); + + if (projectIds.length === 0) { + return { + client: { + id: client.id, + name: client.name, + brand_name: client.brand_name, + brief: client.brief, + accepted_total: client.accepted_total ?? '0', + }, + phases: [], + payments: [], + documents: [], + notes: [], + global_progress_pct: 0, + }; + } + + // Fetch all phases across all projects, ordered by sort_order const phasesRows = await db .select() .from(phases) - .where(eq(phases.client_id, client.id)) + .where(inArray(phases.project_id, projectIds)) .orderBy(phases.sort_order); - // Fetch tasks scoped to this client's phases only (inArray prevents full table scan) const phaseIds = phasesRows.map((p) => p.id); const tasksRows = phaseIds.length === 0 @@ -95,7 +118,6 @@ export async function getClientView(token: string): Promise { .where(inArray(tasks.phase_id, phaseIds)) .orderBy(tasks.sort_order); - // Fetch deliverables scoped to this client's tasks only const taskIds = tasksRows.map((t) => t.id); const deliverablesRows = taskIds.length === 0 @@ -109,19 +131,17 @@ export async function getClientView(token: string): Promise { const paymentsRows = await db .select() .from(payments) - .where(eq(payments.client_id, client.id)); + .where(inArray(payments.project_id, projectIds)); - // Fetch documents const documentsRows = await db .select() .from(documents) - .where(eq(documents.client_id, client.id)); + .where(inArray(documents.project_id, projectIds)); - // Fetch notes (decision log — admin writes, client reads) const notesRows = await db .select() .from(notes) - .where(eq(notes.client_id, client.id)) + .where(inArray(notes.project_id, projectIds)) .orderBy(notes.created_at); // Build hierarchical structure: phases → tasks → deliverables @@ -150,7 +170,6 @@ export async function getClientView(token: string): Promise { }; }); - // Calculate progress for this phase const taskCount = tasksList.length; const doneCount = tasksList.filter((t) => t.status === 'done').length; const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100); @@ -165,26 +184,22 @@ export async function getClientView(token: string): Promise { }; }); - // Calculate global progress across all phases const allDoneCount = tasksRows.filter((t) => t.status === 'done').length; const globalProgressPct = tasksRows.length === 0 ? 0 : Math.round((allDoneCount / tasksRows.length) * 100); - // Map payments — only label and status (no amount exposed to client) const paymentsList = paymentsRows.map((p) => ({ id: p.id, label: p.label, status: p.status as 'da_saldare' | 'inviata' | 'saldato', })); - // Map documents const documentsList = documentsRows.map((d) => ({ id: d.id, label: d.label, url: d.url, })); - // Map notes — ISO timestamps for JSON serialization const notesList = notesRows.map((n) => ({ id: n.id, body: n.body, @@ -197,7 +212,6 @@ export async function getClientView(token: string): Promise { name: client.name, brand_name: client.brand_name, brief: client.brief, - // null coalescing: accepted_total is nullable in schema, default '0' accepted_total: client.accepted_total ?? '0', }, phases: phasesList, diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..46a4779 --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,35 @@ +import { db } from "@/db"; +import { settings } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export const SETTINGS_KEYS = { + TARGET_HOURLY_RATE: "target_hourly_rate", +} as const; + +export async function getSetting(key: string): Promise { + const rows = await db + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, key)) + .limit(1); + return rows[0]?.value ?? null; +} + +export async function updateSetting(key: string, value: string): Promise { + const existing = await getSetting(key); + if (existing !== null) { + await db + .update(settings) + .set({ value, updated_at: new Date() }) + .where(eq(settings.key, key)); + } else { + await db.insert(settings).values({ key, value }); + } + revalidatePath("/admin/impostazioni"); +} + +export async function getTargetHourlyRate(): Promise { + const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE); + return value ? parseFloat(value) : 50; +} \ No newline at end of file