186bb9ea19
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte attive (con override) + selettore schema rate 1/2/3 step; nuova colonna payments.percent per rescalare gli importi preservando label/stato - Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata - Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili - Transcript del cliente visibili in tab Documenti admin e dashboard pubblica - Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry - CLAUDE.md: procedura Deploy & DB Access; push su main automatico Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1081 lines
33 KiB
TypeScript
1081 lines
33 KiB
TypeScript
import { db } from "@/db";
|
|
import {
|
|
clients,
|
|
projects,
|
|
payments,
|
|
phases,
|
|
tasks,
|
|
deliverables,
|
|
comments,
|
|
documents,
|
|
notes,
|
|
time_entries,
|
|
quote_items,
|
|
service_catalog,
|
|
services,
|
|
settings,
|
|
offer_micros,
|
|
offer_macros,
|
|
project_offers,
|
|
offer_phases,
|
|
offer_phase_services,
|
|
quotes,
|
|
leads,
|
|
tags,
|
|
clientTranscripts,
|
|
} from "@/db/schema";
|
|
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
|
import { getPool } from "@/lib/taxonomy";
|
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
|
import type {
|
|
Client,
|
|
Project,
|
|
Phase,
|
|
Task,
|
|
Deliverable,
|
|
Payment,
|
|
Document,
|
|
Note,
|
|
Comment,
|
|
ServiceCatalog,
|
|
Service,
|
|
OfferMicro,
|
|
OfferMacro,
|
|
ProjectOffer,
|
|
OfferPhase,
|
|
OfferPhaseService,
|
|
Quote,
|
|
Lead,
|
|
} from "@/db/schema";
|
|
|
|
// ── ClientWithPayments — used by /admin clients list ─────────────────────────
|
|
|
|
export type ClientWithPayments = {
|
|
id: string;
|
|
name: string;
|
|
brand_name: string;
|
|
token: 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;
|
|
projectBrands: string[];
|
|
ltv: string;
|
|
};
|
|
|
|
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);
|
|
|
|
// Get all projects for these clients (payments/time_entries now scoped to project)
|
|
const clientProjects = await db
|
|
.select({
|
|
id: projects.id,
|
|
client_id: projects.client_id,
|
|
name: projects.name,
|
|
accepted_total: projects.accepted_total,
|
|
archived: projects.archived,
|
|
})
|
|
.from(projects)
|
|
.where(inArray(projects.client_id, clientIds))
|
|
.orderBy(asc(projects.created_at));
|
|
|
|
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,
|
|
slug: c.slug ?? null,
|
|
accepted_total: c.accepted_total ?? "0",
|
|
archived: c.archived ?? false,
|
|
created_at: c.created_at,
|
|
payments: [],
|
|
activeTimerEntryId: null,
|
|
activeTimerStartedAt: null,
|
|
totalTrackedSeconds: 0,
|
|
projectBrands: [],
|
|
ltv: "0.00",
|
|
}));
|
|
}
|
|
|
|
const [allPayments, activeEntries, totals] = 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),
|
|
]);
|
|
|
|
// Aggregate project-scoped data back to client level
|
|
const clientPaymentsMap = new Map<string, typeof allPayments>();
|
|
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) => {
|
|
const active = clientTimerMap.get(c.id);
|
|
const clientPayments = clientPaymentsMap.get(c.id) ?? [];
|
|
const clientProjectsList = clientProjects.filter((p) => p.client_id === c.id);
|
|
const projectBrands = clientProjectsList
|
|
.filter((p) => !p.archived)
|
|
.map((p) => p.name);
|
|
const ltv = clientProjectsList
|
|
.reduce((sum, p) => sum + parseFloat(p.accepted_total ?? "0"), 0)
|
|
.toFixed(2);
|
|
return {
|
|
id: c.id,
|
|
name: c.name,
|
|
brand_name: c.brand_name,
|
|
token: c.token,
|
|
slug: c.slug ?? null,
|
|
accepted_total: c.accepted_total ?? "0",
|
|
archived: c.archived ?? false,
|
|
created_at: c.created_at,
|
|
payments: clientPayments.map((p) => ({
|
|
id: p.id,
|
|
label: p.label,
|
|
status: p.status,
|
|
amount: String(p.amount),
|
|
})),
|
|
activeTimerEntryId: active?.id ?? null,
|
|
activeTimerStartedAt: active?.started_at ?? null,
|
|
totalTrackedSeconds: clientTotalsMap.get(c.id) ?? 0,
|
|
projectBrands,
|
|
ltv,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function getClientById(id: string) {
|
|
const rows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
// ── ProjectOfferWithMicro — used by OffersTab ────────────────────────────────
|
|
|
|
export type ProjectOfferWithMicro = {
|
|
id: string;
|
|
project_id: string;
|
|
micro_id: string;
|
|
micro_internal_name: string;
|
|
micro_public_name: string;
|
|
micro_duration_months: number;
|
|
tier_letter: string | null;
|
|
macro_internal_name: string;
|
|
macro_offer_type: string;
|
|
start_date: Date;
|
|
accepted_total: string | null;
|
|
created_at: Date;
|
|
};
|
|
|
|
// ── 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: Service[];
|
|
};
|
|
|
|
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];
|
|
|
|
// 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(services)
|
|
.where(eq(services.active, true))
|
|
.orderBy(asc(services.name));
|
|
|
|
if (projectIds.length === 0) {
|
|
return {
|
|
client,
|
|
phases: [],
|
|
payments: [],
|
|
documents: [],
|
|
notes: [],
|
|
comments: [],
|
|
quoteItems: [],
|
|
activeServices: activeServiceRows,
|
|
};
|
|
}
|
|
|
|
const phasesRows = await db
|
|
.select()
|
|
.from(phases)
|
|
.where(inArray(phases.project_id, projectIds))
|
|
.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(inArray(payments.project_id, projectIds));
|
|
|
|
const documentsRows = await db
|
|
.select()
|
|
.from(documents)
|
|
.where(inArray(documents.project_id, projectIds))
|
|
.orderBy(asc(documents.created_at));
|
|
|
|
const notesRows = await db
|
|
.select()
|
|
.from(notes)
|
|
.where(inArray(notes.project_id, projectIds))
|
|
.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(inArray(quote_items.project_id, projectIds))
|
|
.orderBy(asc(quote_items.id));
|
|
|
|
return {
|
|
client,
|
|
phases: phasesWithTasks,
|
|
payments: paymentsRows,
|
|
documents: documentsRows,
|
|
notes: notesRows,
|
|
comments: commentsRows,
|
|
quoteItems: quoteItemRows,
|
|
activeServices: activeServiceRows,
|
|
};
|
|
}
|
|
|
|
// ── ServiceWithTags — services + multi-select options (Phase 11 database-view) ─
|
|
// "tag" and "pacchetto" are multi-select pools stored in the polymorphic `tags`
|
|
// table, distinguished by entity_type: "services" (tag) | "services.pacchetto".
|
|
// "category" and "fase" are single-select values stored directly on the row.
|
|
|
|
const TAG_ENTITY = "services";
|
|
const PACCHETTO_ENTITY = "services.pacchetto";
|
|
|
|
export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] };
|
|
|
|
export async function getAllServices(): Promise<ServiceWithTags[]> {
|
|
const rows = await db
|
|
.select({
|
|
id: services.id,
|
|
name: services.name,
|
|
description: services.description,
|
|
unit_price: services.unit_price,
|
|
category: services.category,
|
|
fase: services.fase,
|
|
active: services.active,
|
|
migrated_from: services.migrated_from,
|
|
migrated_id: services.migrated_id,
|
|
created_at: services.created_at,
|
|
tag_name: tags.name,
|
|
tag_type: tags.entity_type,
|
|
})
|
|
.from(services)
|
|
.leftJoin(
|
|
tags,
|
|
and(
|
|
eq(tags.entity_id, services.id),
|
|
inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])
|
|
)
|
|
)
|
|
.orderBy(asc(services.name), asc(tags.name));
|
|
|
|
const serviceMap = new Map<string, ServiceWithTags>();
|
|
for (const row of rows) {
|
|
const { tag_name, tag_type, ...serviceFields } = row;
|
|
if (!serviceMap.has(row.id)) {
|
|
serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] });
|
|
}
|
|
if (tag_name) {
|
|
const target = serviceMap.get(row.id)!;
|
|
if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name);
|
|
else target.tags.push(tag_name);
|
|
}
|
|
}
|
|
return Array.from(serviceMap.values());
|
|
}
|
|
|
|
// ── Shared option pools for the catalog select fields (Notion-style dropdowns) ─
|
|
// Each pool is the set of distinct values currently in use, sorted alphabetically.
|
|
|
|
export type CatalogFieldOptions = {
|
|
tag: string[];
|
|
pacchetto: string[];
|
|
categoria: string[];
|
|
fase: string[];
|
|
};
|
|
|
|
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
|
|
// fase / Offerta (tag) / Pacchetto read from the centralized, persistent
|
|
// taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is
|
|
// hidden in the catalog UI and stays derived from in-use values.
|
|
const [tag, pacchetto, fase, catRows] = await Promise.all([
|
|
getPool("service_offerta"),
|
|
getPool("service_pacchetto"),
|
|
getPool("service_fase"),
|
|
db.selectDistinct({ value: services.category }).from(services),
|
|
]);
|
|
|
|
const categoria = Array.from(
|
|
new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0))
|
|
).sort((a, b) => a.localeCompare(b, "it"));
|
|
|
|
return { tag, pacchetto, categoria, fase };
|
|
}
|
|
|
|
// ── 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: Service[];
|
|
activeTimerEntryId: string | null;
|
|
activeTimerStartedAt: Date | null;
|
|
totalTrackedSeconds: number;
|
|
projectOffers: ProjectOfferWithMicro[];
|
|
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
|
offersAcceptedTotal: number;
|
|
availableMicros: Array<{
|
|
id: string;
|
|
internal_name: string;
|
|
public_name: string;
|
|
duration_months: number;
|
|
tier_letter: string | null;
|
|
public_price: string | null;
|
|
macro_id: string;
|
|
macro_internal_name: string;
|
|
macro_category: string | null;
|
|
macro_offer_type: string;
|
|
}>;
|
|
transcripts: Array<{
|
|
id: string;
|
|
title: string | null;
|
|
call_date: string;
|
|
content: string;
|
|
created_at: Date;
|
|
}>;
|
|
};
|
|
|
|
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, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
|
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(services).where(eq(services.active, true)).orderBy(asc(services.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)),
|
|
// Query A: project offers for this project joined with micro + macro info
|
|
db
|
|
.select({
|
|
id: project_offers.id,
|
|
project_id: project_offers.project_id,
|
|
micro_id: project_offers.micro_id,
|
|
micro_internal_name: offer_micros.internal_name,
|
|
micro_public_name: offer_micros.public_name,
|
|
micro_duration_months: offer_micros.duration_months,
|
|
tier_letter: offer_micros.tier_letter,
|
|
macro_internal_name: offer_macros.internal_name,
|
|
macro_offer_type: offer_macros.offer_type,
|
|
start_date: project_offers.start_date,
|
|
accepted_total: project_offers.accepted_total,
|
|
created_at: project_offers.created_at,
|
|
})
|
|
.from(project_offers)
|
|
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
|
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
|
.where(eq(project_offers.project_id, id))
|
|
.orderBy(asc(project_offers.created_at)),
|
|
// Query B: all tiers of non-archived offers (for the assignment dropdown).
|
|
// ordered oldest-first per (macro, tier) so client-side dedup keeps the
|
|
// oldest until the DB cleanup removes genuine duplicates.
|
|
db
|
|
.select({
|
|
id: offer_micros.id,
|
|
internal_name: offer_micros.internal_name,
|
|
public_name: offer_micros.public_name,
|
|
duration_months: offer_micros.duration_months,
|
|
tier_letter: offer_micros.tier_letter,
|
|
public_price: offer_micros.public_price,
|
|
macro_id: offer_micros.macro_id,
|
|
macro_internal_name: offer_macros.internal_name,
|
|
macro_category: offer_macros.category,
|
|
macro_offer_type: offer_macros.offer_type,
|
|
})
|
|
.from(offer_micros)
|
|
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
|
.where(eq(offer_macros.is_archived, false))
|
|
.orderBy(
|
|
asc(offer_macros.internal_name),
|
|
asc(offer_micros.tier_letter),
|
|
asc(offer_micros.id)
|
|
),
|
|
// Query C: transcripts linked to the project's client
|
|
db
|
|
.select({
|
|
id: clientTranscripts.id,
|
|
title: clientTranscripts.title,
|
|
call_date: clientTranscripts.call_date,
|
|
content: clientTranscripts.content,
|
|
created_at: clientTranscripts.created_at,
|
|
})
|
|
.from(clientTranscripts)
|
|
.where(eq(clientTranscripts.client_id, project.client_id))
|
|
.orderBy(desc(clientTranscripts.call_date)),
|
|
]);
|
|
|
|
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),
|
|
})),
|
|
}));
|
|
|
|
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
|
|
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
|
|
const seenTier = new Set<string>();
|
|
const dedupedMicros = availableMicrosRows.filter((m) => {
|
|
const key = `${m.macro_id}::${m.tier_letter ?? `_null_${m.id}`}`;
|
|
if (seenTier.has(key)) return false;
|
|
seenTier.add(key);
|
|
return true;
|
|
});
|
|
|
|
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
|
|
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
|
0
|
|
);
|
|
|
|
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,
|
|
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
|
offersAcceptedTotal,
|
|
availableMicros: dedupedMicros,
|
|
transcripts: transcriptsRows,
|
|
};
|
|
}
|
|
|
|
// ── Recent time entries — used by TimerTab for the manual-entry recent list ───
|
|
|
|
export type RecentTimeEntry = {
|
|
id: string;
|
|
started_at: Date;
|
|
ended_at: Date | null;
|
|
duration_seconds: number | null;
|
|
};
|
|
|
|
export async function getRecentTimeEntries(
|
|
projectId: string,
|
|
limit = 5
|
|
): Promise<RecentTimeEntry[]> {
|
|
// sql<boolean>`ended_at IS NOT NULL` used to filter closed entries
|
|
// (Drizzle's isNotNull is available in v0.28+ but we use sql for safety)
|
|
return db
|
|
.select({
|
|
id: time_entries.id,
|
|
started_at: time_entries.started_at,
|
|
ended_at: time_entries.ended_at,
|
|
duration_seconds: time_entries.duration_seconds,
|
|
})
|
|
.from(time_entries)
|
|
.where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`))
|
|
.orderBy(desc(time_entries.started_at))
|
|
.limit(limit);
|
|
}
|
|
|
|
// ── 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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ── ClientActiveOfferSummary — used by /admin/clients/[id] offers section ─────
|
|
|
|
export type ClientActiveOfferSummary = {
|
|
offer_id: string;
|
|
project_id: string;
|
|
project_name: string;
|
|
public_name: string; // offer_micros.public_name — NEVER internal_name
|
|
accepted_total: string | null;
|
|
};
|
|
|
|
export async function getClientActiveOffers(clientId: string): Promise<ClientActiveOfferSummary[]> {
|
|
const projectRows = await db
|
|
.select({ id: projects.id, name: projects.name })
|
|
.from(projects)
|
|
.where(eq(projects.client_id, clientId));
|
|
|
|
if (projectRows.length === 0) return [];
|
|
|
|
const projectIds = projectRows.map((p) => p.id);
|
|
const projectNameMap = new Map(projectRows.map((p) => [p.id, p.name]));
|
|
|
|
// Explicit column selection — public_name only, NEVER internal_name
|
|
const rows = await db
|
|
.select({
|
|
offer_id: project_offers.id,
|
|
project_id: project_offers.project_id,
|
|
public_name: offer_micros.public_name,
|
|
accepted_total: project_offers.accepted_total,
|
|
})
|
|
.from(project_offers)
|
|
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
|
.where(inArray(project_offers.project_id, projectIds))
|
|
.orderBy(asc(project_offers.created_at));
|
|
|
|
return rows.map((r) => ({
|
|
offer_id: r.offer_id,
|
|
project_id: r.project_id,
|
|
project_name: projectNameMap.get(r.project_id) ?? "—",
|
|
public_name: r.public_name,
|
|
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
|
|
}));
|
|
}
|
|
|
|
// ── Phase 8 Query Layer: Offer Phases & Quotes ──────────────────────────────
|
|
|
|
/**
|
|
* Get all phases for a given offer micro (ordered by sort_order)
|
|
* Used by: Quote builder, admin phase editor
|
|
*/
|
|
export async function getOfferPhases(microId: string): Promise<OfferPhase[]> {
|
|
return await db
|
|
.select()
|
|
.from(offer_phases)
|
|
.where(eq(offer_phases.micro_id, microId))
|
|
.orderBy(asc(offer_phases.sort_order));
|
|
}
|
|
|
|
/**
|
|
* Get all unified services assigned to a phase
|
|
* Used by: Quote builder, public quote page
|
|
*/
|
|
export async function getOfferPhaseServices(phaseId: string): Promise<Service[]> {
|
|
const phaseServices = await db
|
|
.select({
|
|
service_id: offer_phase_services.service_id,
|
|
})
|
|
.from(offer_phase_services)
|
|
.where(eq(offer_phase_services.phase_id, phaseId));
|
|
|
|
if (phaseServices.length === 0) return [];
|
|
|
|
const serviceIds = phaseServices.map((ps) => ps.service_id);
|
|
|
|
return await db
|
|
.select()
|
|
.from(services)
|
|
.where(inArray(services.id, serviceIds));
|
|
}
|
|
|
|
/**
|
|
* Get full quote by ID with all relations (header + items)
|
|
* Used by: Quote detail page
|
|
*/
|
|
export async function getQuoteById(quoteId: string): Promise<Quote | null> {
|
|
const quote = await db
|
|
.select()
|
|
.from(quotes)
|
|
.where(eq(quotes.id, quoteId));
|
|
|
|
return quote.length > 0 ? quote[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Get quote by public token (for /quote/[token] routes)
|
|
* Used by: Public quote page access validation
|
|
*/
|
|
export async function getQuoteByToken(token: string): Promise<Quote | null> {
|
|
const quote = await db
|
|
.select()
|
|
.from(quotes)
|
|
.where(eq(quotes.token, token));
|
|
|
|
return quote.length > 0 ? quote[0] : null;
|
|
}
|
|
|
|
/**
|
|
* Get all quotes for a client (ordered by created_at DESC)
|
|
* Used by: /admin/clients/[id] detail page
|
|
*/
|
|
export async function getQuotesByClient(clientId: string): Promise<Quote[]> {
|
|
return await db
|
|
.select()
|
|
.from(quotes)
|
|
.where(eq(quotes.client_id, clientId))
|
|
.orderBy(sql`${quotes.created_at} DESC`);
|
|
}
|
|
|
|
/**
|
|
* Get all offer macros with their micros (ordered by sort_order)
|
|
*/
|
|
export async function getAllOfferMacrosWithMicros(): Promise<
|
|
(OfferMacro & { micros: OfferMicro[] })[]
|
|
> {
|
|
const macros = await db
|
|
.select()
|
|
.from(offer_macros)
|
|
.orderBy(asc(offer_macros.sort_order));
|
|
|
|
if (macros.length === 0) return [];
|
|
|
|
const allMicros = await db.select().from(offer_micros);
|
|
|
|
return macros.map((macro) => ({
|
|
...macro,
|
|
micros: allMicros
|
|
.filter((micro) => micro.macro_id === macro.id)
|
|
.sort((a, b) => a.sort_order - b.sort_order),
|
|
}));
|
|
}
|
|
|
|
// ── LeadWithTags — leads + tags (Phase 14 database-view) ─────────────────────
|
|
// Lead tags are stored in the polymorphic `tags` table, scoped by
|
|
// entity_type="leads". `status` is a fixed enum (LEAD_STAGES), not a
|
|
// dynamic pool — exposed via getLeadFieldOptions for the OptionSelect UI.
|
|
|
|
const LEADS_TAG_ENTITY = "leads";
|
|
|
|
export type LeadWithTags = Lead & { tags: string[] };
|
|
|
|
export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
|
|
const rows = await db
|
|
.select({
|
|
id: leads.id,
|
|
name: leads.name,
|
|
email: leads.email,
|
|
phone: leads.phone,
|
|
company: leads.company,
|
|
status: leads.status,
|
|
last_contact_date: leads.last_contact_date,
|
|
next_action: leads.next_action,
|
|
next_action_date: leads.next_action_date,
|
|
notes: leads.notes,
|
|
archived: leads.archived,
|
|
created_at: leads.created_at,
|
|
updated_at: leads.updated_at,
|
|
tag_name: tags.name,
|
|
})
|
|
.from(leads)
|
|
.leftJoin(
|
|
tags,
|
|
and(
|
|
eq(tags.entity_id, leads.id),
|
|
eq(tags.entity_type, LEADS_TAG_ENTITY)
|
|
)
|
|
)
|
|
.where(eq(leads.archived, false)) // converted leads are archived → hidden from pipeline
|
|
.orderBy(desc(leads.updated_at), asc(tags.name));
|
|
|
|
const leadMap = new Map<string, LeadWithTags>();
|
|
for (const row of rows) {
|
|
const { tag_name, ...leadFields } = row;
|
|
if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
|
|
if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
|
|
}
|
|
return Array.from(leadMap.values());
|
|
}
|
|
|
|
// Single lead by id WITH tags — does NOT filter archived (the detail page must
|
|
// still show a converted/archived lead).
|
|
export async function getLeadByIdWithTags(leadId: string): Promise<LeadWithTags | null> {
|
|
const rows = await db
|
|
.select({
|
|
id: leads.id,
|
|
name: leads.name,
|
|
email: leads.email,
|
|
phone: leads.phone,
|
|
company: leads.company,
|
|
status: leads.status,
|
|
last_contact_date: leads.last_contact_date,
|
|
next_action: leads.next_action,
|
|
next_action_date: leads.next_action_date,
|
|
notes: leads.notes,
|
|
archived: leads.archived,
|
|
created_at: leads.created_at,
|
|
updated_at: leads.updated_at,
|
|
tag_name: tags.name,
|
|
})
|
|
.from(leads)
|
|
.leftJoin(
|
|
tags,
|
|
and(eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY))
|
|
)
|
|
.where(eq(leads.id, leadId));
|
|
|
|
if (rows.length === 0) return null;
|
|
let result: LeadWithTags | null = null;
|
|
for (const row of rows) {
|
|
const { tag_name, ...leadFields } = row;
|
|
if (!result) result = { ...leadFields, tags: [] };
|
|
if (tag_name) result.tags.push(tag_name);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// If this lead was converted, returns the client id of the project created from
|
|
// it (projects.created_from_lead_id), else null.
|
|
export async function getClientIdFromLead(leadId: string): Promise<string | null> {
|
|
const [row] = await db
|
|
.select({ client_id: projects.client_id })
|
|
.from(projects)
|
|
.where(eq(projects.created_from_lead_id, leadId))
|
|
.limit(1);
|
|
return row?.client_id ?? null;
|
|
}
|
|
|
|
export type LeadFieldOptions = { status: string[]; tags: string[] };
|
|
|
|
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
|
|
const tagRows = await db
|
|
.selectDistinct({ name: tags.name })
|
|
.from(tags)
|
|
.where(eq(tags.entity_type, LEADS_TAG_ENTITY));
|
|
|
|
return {
|
|
status: [...LEAD_STAGES],
|
|
tags: tagRows
|
|
.map((r) => r.name)
|
|
.sort((a, b) => a.localeCompare(b, "it")),
|
|
};
|
|
} |