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:
+23
-10
@@ -1,6 +1,7 @@
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
|
projects,
|
||||||
phases,
|
phases,
|
||||||
tasks,
|
tasks,
|
||||||
deliverables,
|
deliverables,
|
||||||
@@ -29,12 +30,24 @@ async function seed() {
|
|||||||
|
|
||||||
console.log(`✓ Client created: ${client.name} (ID: ${client.id})`);
|
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
|
const [phase1, phase2, phase3] = await db
|
||||||
.insert(phases)
|
.insert(phases)
|
||||||
.values([
|
.values([
|
||||||
{ client_id: client.id, title: 'Discovery & Strategy', sort_order: 1, status: 'done' },
|
{ project_id: project.id, title: 'Discovery & Strategy', sort_order: 1, status: 'done' },
|
||||||
{ client_id: client.id, title: 'Design & Messaging', sort_order: 2, status: 'active' },
|
{ project_id: project.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: 'Implementation & Launch', sort_order: 3, status: 'upcoming' },
|
||||||
])
|
])
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -68,8 +81,8 @@ async function seed() {
|
|||||||
await db
|
await db
|
||||||
.insert(payments)
|
.insert(payments)
|
||||||
.values([
|
.values([
|
||||||
{ client_id: client.id, label: 'Acconto 50%', amount: '2500.00', status: 'saldato', paid_at: new Date('2026-04-01') },
|
{ project_id: project.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: 'Saldo 50%', amount: '2500.00', status: 'inviata', paid_at: null },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('✓ Payments created (2 total)');
|
console.log('✓ Payments created (2 total)');
|
||||||
@@ -77,8 +90,8 @@ async function seed() {
|
|||||||
await db
|
await db
|
||||||
.insert(documents)
|
.insert(documents)
|
||||||
.values([
|
.values([
|
||||||
{ client_id: client.id, label: 'Brand Guidelines PDF', url: 'https://example.com/brand-guidelines.pdf' },
|
{ project_id: project.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: 'Design Mockups Figma', url: 'https://www.figma.com/file/example' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('✓ Documents created (2 total)');
|
console.log('✓ Documents created (2 total)');
|
||||||
@@ -86,15 +99,15 @@ async function seed() {
|
|||||||
await db
|
await db
|
||||||
.insert(notes)
|
.insert(notes)
|
||||||
.values([
|
.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') },
|
{ 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') },
|
||||||
{ 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: '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('✓ Notes created (2 total)');
|
||||||
|
|
||||||
console.log('\n✨ Seed complete!\n');
|
console.log('\n✨ Seed complete!\n');
|
||||||
console.log('📎 Shareable client link:');
|
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');
|
console.log('This link is unique and secret. Send it to the client via Slack or email.\n');
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
documents,
|
documents,
|
||||||
payments,
|
payments,
|
||||||
clients,
|
clients,
|
||||||
|
projects,
|
||||||
comments,
|
comments,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, asc, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
|
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
|
||||||
@@ -49,18 +50,31 @@ export async function setClientArchived(clientId: string, archived: boolean) {
|
|||||||
|
|
||||||
// ── PHASES ────────────────────────────────────────────────────────────────────
|
// ── 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(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);
|
||||||
|
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
|
||||||
|
|
||||||
const existingPhases = await db
|
const existingPhases = await db
|
||||||
.select({ sort_order: phases.sort_order })
|
.select({ sort_order: phases.sort_order })
|
||||||
.from(phases)
|
.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);
|
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
|
||||||
|
|
||||||
await db.insert(phases).values({
|
await db.insert(phases).values({
|
||||||
client_id: clientId,
|
project_id: projectId,
|
||||||
title,
|
title,
|
||||||
sort_order: maxOrder + 1,
|
sort_order: maxOrder + 1,
|
||||||
status: "upcoming",
|
status: "upcoming",
|
||||||
@@ -146,7 +160,11 @@ export async function addDocument(clientId: string, formData: FormData) {
|
|||||||
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.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}`);
|
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 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
|
// Update accepted_total on client row — denormalized field, quote_items never exposed
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ accepted_total: val.toFixed(2) })
|
.set({ accepted_total: val.toFixed(2) })
|
||||||
.where(eq(clients.id, clientId));
|
.where(eq(clients.id, clientId));
|
||||||
// Split evenly between two payment rows (Acconto 50% + Saldo 50%)
|
|
||||||
const half = (val / 2).toFixed(2);
|
// Get all projects for this client, then update their payment stubs
|
||||||
const paymentsRows = await db
|
const projectRows = await db
|
||||||
.select()
|
.select({ id: projects.id })
|
||||||
.from(payments)
|
.from(projects)
|
||||||
.where(eq(payments.client_id, clientId));
|
.where(eq(projects.client_id, clientId));
|
||||||
for (const p of paymentsRows) {
|
|
||||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.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/${clientId}`);
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
// Only clients.accepted_total is visible to client-facing routes
|
// Only clients.accepted_total is visible to client-facing routes
|
||||||
|
|
||||||
import { db } from "@/db";
|
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 { revalidatePath } from "next/cache";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, asc } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
@@ -16,6 +16,16 @@ 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> {
|
||||||
|
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({
|
const quoteItemSchema = z.object({
|
||||||
service_id: z.string().nullable(),
|
service_id: z.string().nullable(),
|
||||||
custom_label: 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);
|
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) {
|
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);
|
||||||
|
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;
|
||||||
const subtotal = (quantity * unit_price).toFixed(2);
|
const subtotal = (quantity * unit_price).toFixed(2);
|
||||||
|
|
||||||
await db.insert(quote_items).values({
|
await db.insert(quote_items).values({
|
||||||
client_id: clientId,
|
project_id: projectId,
|
||||||
service_id: service_id ?? null,
|
service_id: service_id ?? null,
|
||||||
custom_label: custom_label ?? null,
|
custom_label: custom_label ?? null,
|
||||||
quantity: quantity.toFixed(2),
|
quantity: quantity.toFixed(2),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, payments } from "@/db/schema";
|
import { clients, projects, payments } from "@/db/schema";
|
||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1, "Nome richiesto"),
|
name: z.string().min(1, "Nome richiesto"),
|
||||||
@@ -21,7 +21,6 @@ export async function createClient(formData: FormData) {
|
|||||||
|
|
||||||
const parsed = createClientSchema.safeParse(raw);
|
const parsed = createClientSchema.safeParse(raw);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
// In v1 return errors as thrown string — form displays validation inline
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
parsed.error.issues.map((i) => i.message).join(", ")
|
parsed.error.issues.map((i) => i.message).join(", ")
|
||||||
);
|
);
|
||||||
@@ -35,19 +34,27 @@ export async function createClient(formData: FormData) {
|
|||||||
brand_name: parsed.data.brand_name,
|
brand_name: parsed.data.brand_name,
|
||||||
brief: parsed.data.brief,
|
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%
|
// Create a default project for the client — all work items are project-scoped
|
||||||
// Amounts default to 0 until admin sets accepted_total; admin updates separately
|
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([
|
await db.insert(payments).values([
|
||||||
{
|
{
|
||||||
client_id: newClient.id,
|
project_id: newProject.id,
|
||||||
label: "Acconto 50%",
|
label: "Acconto 50%",
|
||||||
amount: "0",
|
amount: "0",
|
||||||
status: "da_saldare",
|
status: "da_saldare",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
client_id: newClient.id,
|
project_id: newProject.id,
|
||||||
label: "Saldo 50%",
|
label: "Saldo 50%",
|
||||||
amount: "0",
|
amount: "0",
|
||||||
status: "da_saldare",
|
status: "da_saldare",
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { NavBar } from "@/components/admin/NavBar";
|
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,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<NavBar />
|
{session && <NavBar />}
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
|
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { time_entries } from "@/db/schema";
|
import { time_entries, projects } from "@/db/schema";
|
||||||
import { eq, isNull } from "drizzle-orm";
|
import { eq, isNull, asc } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export async function startTimer(clientId: string): Promise<{ entryId: string }> {
|
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
||||||
// Stop any currently running session (for any client) before starting a new one
|
// Stop any currently running session before starting a new one
|
||||||
const running = await db
|
const running = await db
|
||||||
.select({ id: time_entries.id })
|
.select({ id: time_entries.id })
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
@@ -30,11 +30,23 @@ export async function startTimer(clientId: string): Promise<{ entryId: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = nanoid();
|
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");
|
revalidatePath("/admin");
|
||||||
return { entryId: id };
|
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> {
|
export async function stopTimer(entryId: string): Promise<void> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ started_at: time_entries.started_at })
|
.select({ started_at: time_entries.started_at })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -26,12 +26,13 @@ export async function POST(request: NextRequest) {
|
|||||||
const clientId = clientRows[0].id;
|
const clientId = clientRows[0].id;
|
||||||
|
|
||||||
// Verify deliverable belongs to this client (prevents cross-client approval)
|
// Verify deliverable belongs to this client (prevents cross-client approval)
|
||||||
// deliverable → task → phase → client
|
// deliverable → task → phase → project → client
|
||||||
const ownershipCheck = await db
|
const ownershipCheck = await db
|
||||||
.select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at })
|
.select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at })
|
||||||
.from(deliverables)
|
.from(deliverables)
|
||||||
.innerJoin(tasks, eq(deliverables.task_id, tasks.id))
|
.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))
|
.where(eq(deliverables.id, deliverableId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
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({
|
const commentSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -43,46 +43,50 @@ export async function POST(request: NextRequest) {
|
|||||||
if (entity_id !== clientId) {
|
if (entity_id !== clientId) {
|
||||||
return NextResponse.json({ error: "Entity non valida" }, { status: 403 });
|
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 {
|
} 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
|
const phasesForClient = await db
|
||||||
.select({ id: phases.id })
|
.select({ id: phases.id })
|
||||||
.from(phases)
|
.from(phases)
|
||||||
.where(eq(phases.client_id, clientId));
|
.where(inArray(phases.project_id, projectIds));
|
||||||
const phaseIds = phasesForClient.map((p) => p.id);
|
const phaseIds = phasesForClient.map((p) => p.id);
|
||||||
|
|
||||||
if (phaseIds.length === 0) {
|
if (phaseIds.length === 0) {
|
||||||
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskRows = await db
|
const taskRows = await db
|
||||||
.select({ id: tasks.id })
|
.select({ id: tasks.id })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(inArray(tasks.phase_id, phaseIds));
|
.where(inArray(tasks.phase_id, phaseIds));
|
||||||
const taskIds = taskRows.map((r) => r.id);
|
|
||||||
if (taskIds.length === 0) {
|
if (entity_type === "task") {
|
||||||
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
|
if (!taskRows.find((r) => r.id === entity_id)) {
|
||||||
}
|
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
|
||||||
const delivRows = await db
|
}
|
||||||
.select({ id: deliverables.id })
|
} else {
|
||||||
.from(deliverables)
|
// deliverable
|
||||||
.where(inArray(deliverables.task_id, taskIds));
|
const taskIds = taskRows.map((r) => r.id);
|
||||||
if (!delivRows.find((r) => r.id === entity_id)) {
|
if (taskIds.length === 0) {
|
||||||
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useTransition } from "react";
|
import { useState, useEffect, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 {
|
function formatDuration(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@@ -47,7 +47,7 @@ export function TimerCell({
|
|||||||
if (isRunning && activeEntryId) {
|
if (isRunning && activeEntryId) {
|
||||||
await stopTimer(activeEntryId);
|
await stopTimer(activeEntryId);
|
||||||
} else {
|
} else {
|
||||||
await startTimer(clientId);
|
await startTimerForClient(clientId);
|
||||||
}
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
|
|||||||
+62
-25
@@ -22,7 +22,9 @@ export const clients = pgTable("clients", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.unique()
|
.unique()
|
||||||
.$defaultFn(() => nanoid()),
|
.$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(
|
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default(
|
||||||
"0"
|
"0"
|
||||||
),
|
),
|
||||||
@@ -32,14 +34,28 @@ export const clients = pgTable("clients", {
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ PHASES ============
|
// ============ PROJECTS ============
|
||||||
export const phases = pgTable("phases", {
|
export const projects = pgTable("projects", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
client_id: text("client_id")
|
client_id: text("client_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
.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(),
|
title: text("title").notNull(),
|
||||||
sort_order: integer("sort_order").notNull().default(0),
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
status: text("status").notNull().default("upcoming"), // upcoming | active | done
|
status: text("status").notNull().default("upcoming"), // upcoming | active | done
|
||||||
@@ -93,9 +109,9 @@ export const payments = pgTable("payments", {
|
|||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
client_id: text("client_id")
|
project_id: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
||||||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||||||
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
||||||
@@ -107,9 +123,9 @@ export const documents = pgTable("documents", {
|
|||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
client_id: text("client_id")
|
project_id: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
label: text("label").notNull(),
|
label: text("label").notNull(),
|
||||||
url: text("url").notNull(), // external URL only — no file hosting in v1
|
url: text("url").notNull(), // external URL only — no file hosting in v1
|
||||||
created_at: timestamp("created_at", { withTimezone: true })
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -122,23 +138,23 @@ export const notes = pgTable("notes", {
|
|||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
client_id: text("client_id")
|
project_id: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
created_at: timestamp("created_at", { withTimezone: true })
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ TIME ENTRIES (admin time tracking per client) ============
|
// ============ TIME ENTRIES (admin time tracking per project) ============
|
||||||
export const time_entries = pgTable("time_entries", {
|
export const time_entries = pgTable("time_entries", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
client_id: text("client_id")
|
project_id: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||||
duration_seconds: integer("duration_seconds"), // set on stop
|
duration_seconds: integer("duration_seconds"), // set on stop
|
||||||
@@ -160,9 +176,9 @@ export const quote_items = pgTable("quote_items", {
|
|||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
client_id: text("client_id")
|
project_id: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
service_id: text("service_id")
|
service_id: text("service_id")
|
||||||
.references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref
|
.references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref
|
||||||
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
|
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)
|
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 ============
|
// ============ RELATIONS ============
|
||||||
|
|
||||||
export const clientsRelations = relations(clients, ({ many }) => ({
|
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),
|
phases: many(phases),
|
||||||
payments: many(payments),
|
payments: many(payments),
|
||||||
documents: many(documents),
|
documents: many(documents),
|
||||||
notes: many(notes),
|
notes: many(notes),
|
||||||
quote_items: many(quote_items),
|
quote_items: many(quote_items),
|
||||||
|
time_entries: many(time_entries),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const phasesRelations = relations(phases, ({ one, many }) => ({
|
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),
|
tasks: many(tasks),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -200,27 +229,31 @@ export const commentsRelations = relations(comments, (_) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const paymentsRelations = relations(payments, ({ one }) => ({
|
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||||
client: one(clients, {
|
project: one(projects, {
|
||||||
fields: [payments.client_id],
|
fields: [payments.project_id],
|
||||||
references: [clients.id],
|
references: [projects.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||||
client: one(clients, {
|
project: one(projects, {
|
||||||
fields: [documents.client_id],
|
fields: [documents.project_id],
|
||||||
references: [clients.id],
|
references: [projects.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const notesRelations = relations(notes, ({ one }) => ({
|
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 }) => ({
|
export const quoteItemsRelations = relations(quote_items, ({ one }) => ({
|
||||||
client: one(clients, {
|
project: one(projects, {
|
||||||
fields: [quote_items.client_id],
|
fields: [quote_items.project_id],
|
||||||
references: [clients.id],
|
references: [projects.id],
|
||||||
}),
|
}),
|
||||||
service: one(service_catalog, {
|
service: one(service_catalog, {
|
||||||
fields: [quote_items.service_id],
|
fields: [quote_items.service_id],
|
||||||
@@ -239,6 +272,8 @@ export const serviceCatalogRelations = relations(
|
|||||||
|
|
||||||
export type Client = typeof clients.$inferSelect;
|
export type Client = typeof clients.$inferSelect;
|
||||||
export type NewClient = typeof clients.$inferInsert;
|
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 Phase = typeof phases.$inferSelect;
|
||||||
export type NewPhase = typeof phases.$inferInsert;
|
export type NewPhase = typeof phases.$inferInsert;
|
||||||
export type Task = typeof tasks.$inferSelect;
|
export type Task = typeof tasks.$inferSelect;
|
||||||
@@ -259,3 +294,5 @@ export type QuoteItem = typeof quote_items.$inferSelect;
|
|||||||
export type NewQuoteItem = typeof quote_items.$inferInsert;
|
export type NewQuoteItem = typeof quote_items.$inferInsert;
|
||||||
export type TimeEntry = typeof time_entries.$inferSelect;
|
export type TimeEntry = typeof time_entries.$inferSelect;
|
||||||
export type NewTimeEntry = typeof time_entries.$inferInsert;
|
export type NewTimeEntry = typeof time_entries.$inferInsert;
|
||||||
|
export type Setting = typeof settings.$inferSelect;
|
||||||
|
export type NewSetting = typeof settings.$inferInsert;
|
||||||
+363
-29
@@ -1,6 +1,7 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
|
projects,
|
||||||
payments,
|
payments,
|
||||||
phases,
|
phases,
|
||||||
tasks,
|
tasks,
|
||||||
@@ -11,10 +12,12 @@ import {
|
|||||||
time_entries,
|
time_entries,
|
||||||
quote_items,
|
quote_items,
|
||||||
service_catalog,
|
service_catalog,
|
||||||
|
settings,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
Client,
|
Client,
|
||||||
|
Project,
|
||||||
Phase,
|
Phase,
|
||||||
Task,
|
Task,
|
||||||
Deliverable,
|
Deliverable,
|
||||||
@@ -25,6 +28,8 @@ import type {
|
|||||||
ServiceCatalog,
|
ServiceCatalog,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
|
||||||
|
// ── ClientWithPayments — used by /admin clients list ─────────────────────────
|
||||||
|
|
||||||
export type ClientWithPayments = {
|
export type ClientWithPayments = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -55,37 +60,79 @@ export async function getAllClientsWithPayments(
|
|||||||
|
|
||||||
const clientIds = visible.map((c) => c.id);
|
const clientIds = visible.map((c) => c.id);
|
||||||
|
|
||||||
const [allPayments, activeEntries, totals] = await Promise.all([
|
// Get all projects for these clients (payments/time_entries now scoped to project)
|
||||||
db.select().from(payments),
|
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
|
db
|
||||||
.select({
|
.select({
|
||||||
id: time_entries.id,
|
id: time_entries.id,
|
||||||
client_id: time_entries.client_id,
|
project_id: time_entries.project_id,
|
||||||
started_at: time_entries.started_at,
|
started_at: time_entries.started_at,
|
||||||
})
|
})
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(isNull(time_entries.ended_at)),
|
.where(isNull(time_entries.ended_at)),
|
||||||
|
|
||||||
// Total tracked seconds per client (completed sessions)
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
client_id: time_entries.client_id,
|
project_id: time_entries.project_id,
|
||||||
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
||||||
})
|
})
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(inArray(time_entries.client_id, clientIds))
|
.where(inArray(time_entries.project_id, projectIds))
|
||||||
.groupBy(time_entries.client_id),
|
.groupBy(time_entries.project_id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalMap = new Map(totals.map((r) => [r.client_id, parseInt(r.total)]));
|
// Aggregate project-scoped data back to client level
|
||||||
const activeMap = new Map(
|
const clientPaymentsMap = new Map<string, typeof allPayments>();
|
||||||
activeEntries.map((r) => [r.client_id, { id: r.id, started_at: r.started_at }])
|
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<string, { id: string; started_at: Date }>();
|
||||||
|
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<string, number>();
|
||||||
|
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) => {
|
return visible.map((c) => {
|
||||||
const active = activeMap.get(c.id);
|
const active = clientTimerMap.get(c.id);
|
||||||
|
const clientPayments = clientPaymentsMap.get(c.id) ?? [];
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -94,12 +141,15 @@ export async function getAllClientsWithPayments(
|
|||||||
accepted_total: c.accepted_total ?? "0",
|
accepted_total: c.accepted_total ?? "0",
|
||||||
archived: c.archived ?? false,
|
archived: c.archived ?? false,
|
||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
payments: allPayments
|
payments: clientPayments.map((p) => ({
|
||||||
.filter((p) => p.client_id === c.id)
|
id: p.id,
|
||||||
.map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })),
|
label: p.label,
|
||||||
|
status: p.status,
|
||||||
|
amount: String(p.amount),
|
||||||
|
})),
|
||||||
activeTimerEntryId: active?.id ?? null,
|
activeTimerEntryId: active?.id ?? null,
|
||||||
activeTimerStartedAt: active?.started_at ?? 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<ClientFullDetail
|
|||||||
if (clientRows.length === 0) return null;
|
if (clientRows.length === 0) return null;
|
||||||
const client = clientRows[0];
|
const client = clientRows[0];
|
||||||
|
|
||||||
|
// 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, id));
|
||||||
|
const projectIds = projectRows.map((p) => 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
|
const phasesRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(phases)
|
.from(phases)
|
||||||
.where(eq(phases.client_id, id))
|
.where(inArray(phases.project_id, projectIds))
|
||||||
.orderBy(asc(phases.sort_order));
|
.orderBy(asc(phases.sort_order));
|
||||||
|
|
||||||
const phaseIds = phasesRows.map((p) => p.id);
|
const phaseIds = phasesRows.map((p) => p.id);
|
||||||
@@ -162,18 +238,21 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
? []
|
? []
|
||||||
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
|
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
|
||||||
|
|
||||||
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
|
const paymentsRows = await db
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(inArray(payments.project_id, projectIds));
|
||||||
|
|
||||||
const documentsRows = await db
|
const documentsRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(documents)
|
.from(documents)
|
||||||
.where(eq(documents.client_id, id))
|
.where(inArray(documents.project_id, projectIds))
|
||||||
.orderBy(asc(documents.created_at));
|
.orderBy(asc(documents.created_at));
|
||||||
|
|
||||||
const notesRows = await db
|
const notesRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(notes)
|
.from(notes)
|
||||||
.where(eq(notes.client_id, id))
|
.where(inArray(notes.project_id, projectIds))
|
||||||
.orderBy(asc(notes.created_at));
|
.orderBy(asc(notes.created_at));
|
||||||
|
|
||||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
@@ -209,15 +288,9 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
})
|
})
|
||||||
.from(quote_items)
|
.from(quote_items)
|
||||||
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
|
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
|
||||||
.where(eq(quote_items.client_id, id))
|
.where(inArray(quote_items.project_id, projectIds))
|
||||||
.orderBy(asc(quote_items.id));
|
.orderBy(asc(quote_items.id));
|
||||||
|
|
||||||
const activeServiceRows = await db
|
|
||||||
.select()
|
|
||||||
.from(service_catalog)
|
|
||||||
.where(eq(service_catalog.active, true))
|
|
||||||
.orderBy(asc(service_catalog.name));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
phases: phasesWithTasks,
|
phases: phasesWithTasks,
|
||||||
@@ -236,3 +309,264 @@ export async function getAllServices(): Promise<ServiceCatalog[]> {
|
|||||||
.from(service_catalog)
|
.from(service_catalog)
|
||||||
.orderBy(asc(service_catalog.name));
|
.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<ProjectWithPayments[]> {
|
||||||
|
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<string>`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<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||||
|
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<ProjectFullDetail | null> {
|
||||||
|
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<string>`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<string>`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<ClientWithProjects | null> {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, payments, time_entries } from "@/db/schema";
|
import { clients, payments, time_entries, projects } from "@/db/schema";
|
||||||
import { sql, and, eq } from "drizzle-orm";
|
import { sql, and, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
export async function getAnalyticsByYear(year: number) {
|
export async function getAnalyticsByYear(year: number) {
|
||||||
const [contracted] = await db
|
const [contracted] = await db
|
||||||
@@ -81,7 +81,7 @@ export type ClientTimeRow = {
|
|||||||
export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
|
export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
client_id: time_entries.client_id,
|
project_id: time_entries.project_id,
|
||||||
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
||||||
})
|
})
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
@@ -89,18 +89,34 @@ export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
|
|||||||
sql`${time_entries.ended_at} is not null
|
sql`${time_entries.ended_at} is not null
|
||||||
and extract(year from ${time_entries.started_at}) = ${year}`
|
and extract(year from ${time_entries.started_at}) = ${year}`
|
||||||
)
|
)
|
||||||
.groupBy(time_entries.client_id);
|
.groupBy(time_entries.project_id);
|
||||||
|
|
||||||
if (rows.length === 0) return [];
|
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 allClients = await db.select({ id: clients.id, name: clients.name }).from(clients);
|
||||||
const nameMap = new Map(allClients.map((c) => [c.id, c.name]));
|
const nameMap = new Map(allClients.map((c) => [c.id, c.name]));
|
||||||
|
|
||||||
return rows
|
// Aggregate by client_id (a client may have multiple projects)
|
||||||
.map((r) => ({
|
const clientTotals = new Map<string, number>();
|
||||||
clientId: r.client_id,
|
for (const row of rows) {
|
||||||
clientName: nameMap.get(r.client_id) ?? r.client_id,
|
const clientId = projectToClient.get(row.project_id);
|
||||||
totalSeconds: parseInt(r.total),
|
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);
|
.sort((a, b) => b.totalSeconds - a.totalSeconds);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-17
@@ -1,6 +1,6 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
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.
|
* 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.
|
* getClientView: Fetch all client data and return only the ClientView shape.
|
||||||
* NEVER queries quote_items, service_catalog, or service prices.
|
* 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<ClientView | null> {
|
export async function getClientView(token: string): Promise<ClientView | null> {
|
||||||
// Fetch client by token (Architecture constraint: token is separate from id PK)
|
// Fetch client by token (Architecture constraint: token is separate from id PK)
|
||||||
@@ -77,14 +77,37 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
|||||||
|
|
||||||
const client = clientRow[0];
|
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
|
const phasesRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(phases)
|
.from(phases)
|
||||||
.where(eq(phases.client_id, client.id))
|
.where(inArray(phases.project_id, projectIds))
|
||||||
.orderBy(phases.sort_order);
|
.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 phaseIds = phasesRows.map((p) => p.id);
|
||||||
const tasksRows =
|
const tasksRows =
|
||||||
phaseIds.length === 0
|
phaseIds.length === 0
|
||||||
@@ -95,7 +118,6 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
|||||||
.where(inArray(tasks.phase_id, phaseIds))
|
.where(inArray(tasks.phase_id, phaseIds))
|
||||||
.orderBy(tasks.sort_order);
|
.orderBy(tasks.sort_order);
|
||||||
|
|
||||||
// Fetch deliverables scoped to this client's tasks only
|
|
||||||
const taskIds = tasksRows.map((t) => t.id);
|
const taskIds = tasksRows.map((t) => t.id);
|
||||||
const deliverablesRows =
|
const deliverablesRows =
|
||||||
taskIds.length === 0
|
taskIds.length === 0
|
||||||
@@ -109,19 +131,17 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
|||||||
const paymentsRows = await db
|
const paymentsRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq(payments.client_id, client.id));
|
.where(inArray(payments.project_id, projectIds));
|
||||||
|
|
||||||
// Fetch documents
|
|
||||||
const documentsRows = await db
|
const documentsRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(documents)
|
.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
|
const notesRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(notes)
|
.from(notes)
|
||||||
.where(eq(notes.client_id, client.id))
|
.where(inArray(notes.project_id, projectIds))
|
||||||
.orderBy(notes.created_at);
|
.orderBy(notes.created_at);
|
||||||
|
|
||||||
// Build hierarchical structure: phases → tasks → deliverables
|
// Build hierarchical structure: phases → tasks → deliverables
|
||||||
@@ -150,7 +170,6 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate progress for this phase
|
|
||||||
const taskCount = tasksList.length;
|
const taskCount = tasksList.length;
|
||||||
const doneCount = tasksList.filter((t) => t.status === 'done').length;
|
const doneCount = tasksList.filter((t) => t.status === 'done').length;
|
||||||
const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100);
|
const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100);
|
||||||
@@ -165,26 +184,22 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate global progress across all phases
|
|
||||||
const allDoneCount = tasksRows.filter((t) => t.status === 'done').length;
|
const allDoneCount = tasksRows.filter((t) => t.status === 'done').length;
|
||||||
const globalProgressPct =
|
const globalProgressPct =
|
||||||
tasksRows.length === 0 ? 0 : Math.round((allDoneCount / tasksRows.length) * 100);
|
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) => ({
|
const paymentsList = paymentsRows.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
label: p.label,
|
label: p.label,
|
||||||
status: p.status as 'da_saldare' | 'inviata' | 'saldato',
|
status: p.status as 'da_saldare' | 'inviata' | 'saldato',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Map documents
|
|
||||||
const documentsList = documentsRows.map((d) => ({
|
const documentsList = documentsRows.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
label: d.label,
|
label: d.label,
|
||||||
url: d.url,
|
url: d.url,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Map notes — ISO timestamps for JSON serialization
|
|
||||||
const notesList = notesRows.map((n) => ({
|
const notesList = notesRows.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
body: n.body,
|
body: n.body,
|
||||||
@@ -197,7 +212,6 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
|||||||
name: client.name,
|
name: client.name,
|
||||||
brand_name: client.brand_name,
|
brand_name: client.brand_name,
|
||||||
brief: client.brief,
|
brief: client.brief,
|
||||||
// null coalescing: accepted_total is nullable in schema, default '0'
|
|
||||||
accepted_total: client.accepted_total ?? '0',
|
accepted_total: client.accepted_total ?? '0',
|
||||||
},
|
},
|
||||||
phases: phasesList,
|
phases: phasesList,
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
||||||
|
return value ? parseFloat(value) : 50;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user