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:
2026-05-21 21:58:15 +02:00
parent 44d4fde0a5
commit 63c9f750df
14 changed files with 673 additions and 156 deletions
+25 -9
View File
@@ -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<ClientTimeRow[]> {
const rows = await db
.select({
client_id: time_entries.client_id,
project_id: time_entries.project_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
@@ -89,18 +89,34 @@ export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
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<string, number>();
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);
}