feat(04-01): multi-project schema migration — projects, settings, FK pivot
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<NavBar />
|
||||
{session && <NavBar />}
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
const rows = await db
|
||||
.select({ started_at: time_entries.started_at })
|
||||
|
||||
Reference in New Issue
Block a user