238 lines
6.6 KiB
TypeScript
238 lines
6.6 KiB
TypeScript
import { db } from "@/db";
|
|
import {
|
|
clients,
|
|
payments,
|
|
phases,
|
|
tasks,
|
|
deliverables,
|
|
comments,
|
|
documents,
|
|
notes,
|
|
time_entries,
|
|
quote_items,
|
|
service_catalog,
|
|
} from "@/db/schema";
|
|
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
|
import type {
|
|
Client,
|
|
Phase,
|
|
Task,
|
|
Deliverable,
|
|
Payment,
|
|
Document,
|
|
Note,
|
|
Comment,
|
|
ServiceCatalog,
|
|
} from "@/db/schema";
|
|
|
|
export type ClientWithPayments = {
|
|
id: string;
|
|
name: string;
|
|
brand_name: string;
|
|
token: string;
|
|
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 getAllClientsWithPayments(
|
|
includeArchived = false
|
|
): Promise<ClientWithPayments[]> {
|
|
const allClients = await db
|
|
.select()
|
|
.from(clients)
|
|
.orderBy(clients.created_at);
|
|
|
|
const visible = includeArchived
|
|
? allClients
|
|
: allClients.filter((c) => !c.archived);
|
|
|
|
if (visible.length === 0) return [];
|
|
|
|
const clientIds = visible.map((c) => c.id);
|
|
|
|
const [allPayments, activeEntries, totals] = await Promise.all([
|
|
db.select().from(payments),
|
|
|
|
// Running timer sessions (ended_at IS NULL)
|
|
db
|
|
.select({
|
|
id: time_entries.id,
|
|
client_id: time_entries.client_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,
|
|
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
|
})
|
|
.from(time_entries)
|
|
.where(inArray(time_entries.client_id, clientIds))
|
|
.groupBy(time_entries.client_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 }])
|
|
);
|
|
|
|
return visible.map((c) => {
|
|
const active = activeMap.get(c.id);
|
|
return {
|
|
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: allPayments
|
|
.filter((p) => p.client_id === c.id)
|
|
.map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })),
|
|
activeTimerEntryId: active?.id ?? null,
|
|
activeTimerStartedAt: active?.started_at ?? null,
|
|
totalTrackedSeconds: totalMap.get(c.id) ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function getClientById(id: string) {
|
|
const rows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
|
|
|
|
// quote_items NEVER exposed via client API — admin workspace query only
|
|
export type QuoteItemWithLabel = {
|
|
id: string;
|
|
label: string; // COALESCE(service_catalog.name, quote_items.custom_label)
|
|
custom_label: string | null;
|
|
service_id: string | null;
|
|
quantity: string;
|
|
unit_price: string; // snapshotted — never joined back to service_catalog.unit_price
|
|
subtotal: string;
|
|
};
|
|
|
|
export type ClientFullDetail = {
|
|
client: Client;
|
|
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
|
payments: Payment[];
|
|
documents: Document[];
|
|
notes: Note[];
|
|
comments: Comment[];
|
|
quoteItems: QuoteItemWithLabel[];
|
|
activeServices: ServiceCatalog[];
|
|
};
|
|
|
|
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));
|
|
|
|
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),
|
|
})),
|
|
}));
|
|
|
|
// quote_items NEVER exposed via client API — admin workspace query only
|
|
const quoteItemRows: QuoteItemWithLabel[] = await 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.client_id, 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 {
|
|
client,
|
|
phases: phasesWithTasks,
|
|
payments: paymentsRows,
|
|
documents: documentsRows,
|
|
notes: notesRows,
|
|
comments: commentsRows,
|
|
quoteItems: quoteItemRows,
|
|
activeServices: activeServiceRows,
|
|
};
|
|
}
|
|
|
|
export async function getAllServices(): Promise<ServiceCatalog[]> {
|
|
return db
|
|
.select()
|
|
.from(service_catalog)
|
|
.orderBy(asc(service_catalog.name));
|
|
} |