feat(02-03): install @radix-ui/react-tabs, add getClientFullDetail, create Server Actions

- Add shadcn tabs component (src/components/ui/tabs.tsx) backed by @radix-ui/react-tabs
- Extend admin-queries.ts with getClientFullDetail() — fetches client + phases + tasks + deliverables + payments + documents + notes + comments in one call
- Create src/app/admin/clients/[id]/actions.ts with all mutations: addPhase, updatePhaseStatus, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment
- All actions include server-side allowlist validation and revalidatePath
- approved_at immutability enforced by omission in addDeliverable
This commit is contained in:
Simone Cavalli
2026-05-15 20:10:10 +02:00
parent feede57c05
commit 7733566f5b
5 changed files with 541 additions and 2 deletions
+112 -2
View File
@@ -1,6 +1,25 @@
import { db } from "@/db";
import { clients, payments } from "@/db/schema";
import { eq } from "drizzle-orm";
import {
clients,
payments,
phases,
tasks,
deliverables,
comments,
documents,
notes,
} from "@/db/schema";
import { eq, inArray, asc } from "drizzle-orm";
import type {
Client,
Phase,
Task,
Deliverable,
Payment,
Document,
Note,
Comment,
} from "@/db/schema";
export type ClientWithPayments = {
id: string;
@@ -55,3 +74,94 @@ export async function getClientById(id: string) {
.limit(1);
return rows[0] ?? null;
}
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
export type ClientFullDetail = {
client: Client;
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
};
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
if (clientRows.length === 0) return null;
const client = clientRows[0];
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.client_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 = await db
.select()
.from(payments)
.where(eq(payments.client_id, id));
const documentsRows = await db
.select()
.from(documents)
.where(eq(documents.client_id, id))
.orderBy(asc(documents.created_at));
const notesRows = await db
.select()
.from(notes)
.where(eq(notes.client_id, id))
.orderBy(asc(notes.created_at));
// Fetch all comments for tasks and deliverables belonging to this client
const allEntityIds = [...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) => {
const phaseTasks = tasksRows
.filter((t) => t.phase_id === phase.id)
.map((task) => ({
...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
}));
return { ...phase, tasks: phaseTasks };
});
return {
client,
phases: phasesWithTasks,
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
};
}