chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import { and, count, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companies } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { clients, clientNotes } from '@/lib/db/schema/clients';
|
||||
import { yachts, yachtNotes } from '@/lib/db/schema/yachts';
|
||||
import { companies, companyNotes } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { invoices, expenses } from '@/lib/db/schema/financial';
|
||||
import { payments } from '@/lib/db/schema/pipeline';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
@@ -22,7 +25,7 @@ const DEFAULT_PIPELINE_WEIGHTS: Record<string, number> = STAGE_WEIGHTS;
|
||||
|
||||
/**
|
||||
* Pipeline KPIs. When `range` is supplied the pipeline-value calculation
|
||||
* is scoped to interests whose `createdAt` falls inside the range — lets
|
||||
* is scoped to interests whose `createdAt` falls inside the range - lets
|
||||
* leadership see "what was added to the pipeline this period" rather
|
||||
* than the all-time snapshot. Active-interests count + occupancy are
|
||||
* always all-active (no temporal sense for "active right now").
|
||||
@@ -33,7 +36,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
.from(clients)
|
||||
.where(and(eq(clients.portId, portId), isNull(clients.archivedAt)));
|
||||
|
||||
// Range filter — clamp to the interest's createdAt. Returns undefined
|
||||
// Range filter - clamp to the interest's createdAt. Returns undefined
|
||||
// when no range is provided so the existing all-time queries stay
|
||||
// unaffected.
|
||||
const rangeClause = range
|
||||
@@ -55,7 +58,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
// Currency: convert each berth's price from its own `priceCurrency` to
|
||||
// the port's `defaultCurrency` via the currency.service rate table.
|
||||
// Pre-2026-05-14 we summed mixed-currency numbers verbatim and
|
||||
// labeled the total as USD — a silent lie when a port priced any
|
||||
// labeled the total as USD - a silent lie when a port priced any
|
||||
// berth in a non-USD currency.
|
||||
const [portRow] = await db
|
||||
.select({ defaultCurrency: ports.defaultCurrency })
|
||||
@@ -93,7 +96,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
if (converted) {
|
||||
pipelineValue += converted.result;
|
||||
} else {
|
||||
// Missing rate — degrade to summing raw amount so the tile shows
|
||||
// Missing rate - degrade to summing raw amount so the tile shows
|
||||
// an approximate-but-recognizable number rather than swallowing
|
||||
// the berth entirely. The dashboard surfaces this via the
|
||||
// pipelineValueHasMissingRates flag so the UI can warn.
|
||||
@@ -102,7 +105,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
}
|
||||
|
||||
// Occupancy rate: berths with `status='sold'` / total * 100. Per the
|
||||
// 2026-05-14 decision, `under_offer` is NOT occupied — a reservation
|
||||
// 2026-05-14 decision, `under_offer` is NOT occupied - a reservation
|
||||
// blocks the berth from sale to others but the berth is still
|
||||
// technically available until the sale closes.
|
||||
const allBerthsRows = await db
|
||||
@@ -191,7 +194,7 @@ export async function getRevenueForecast(portId: string, range?: { from: Date; t
|
||||
: activeInterestsWhere(portId),
|
||||
);
|
||||
|
||||
// Build stageBreakdown — gross value, weighted value, per-stage weight,
|
||||
// Build stageBreakdown - gross value, weighted value, per-stage weight,
|
||||
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
||||
// all surface to callers. The dashboard tile shows a warning chip when
|
||||
// any deals in a stage are missing a berth price so the $0 line item
|
||||
@@ -263,7 +266,7 @@ export async function getBerthStatusDistribution(portId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Top 5 active interests closest to closing — ranked by pipeline stage
|
||||
* Top 5 active interests closest to closing - ranked by pipeline stage
|
||||
* (further = closer to closing) with most-recent activity as a
|
||||
* tiebreaker. Surfaces the deals reps should actually be chasing on the
|
||||
* dashboard without making them open the pipeline board.
|
||||
@@ -271,7 +274,7 @@ export async function getBerthStatusDistribution(portId: string) {
|
||||
export async function getHotDeals(portId: string, limit = 5) {
|
||||
// Stage rank: bigger = closer to closing. Mirrors the 7-stage pipeline
|
||||
// shipped 2026-05-14 (pipeline-refactor wave). Nurturing is a holding
|
||||
// pen below qualified — supply-constrained ports flip deals there when
|
||||
// pen below qualified - supply-constrained ports flip deals there when
|
||||
// they can't progress. Won/lost/cancelled outcomes are filtered out via
|
||||
// `outcome IS NULL` below, so they don't need a rank slot.
|
||||
const rank = sql<number>`CASE ${interests.pipelineStage}
|
||||
@@ -318,7 +321,7 @@ export async function getHotDeals(portId: string, limit = 5) {
|
||||
/**
|
||||
* Source-conversion breakdown for the marketing widget. Returns per-
|
||||
* source totals (active + won + lost) and a derived conversion rate so
|
||||
* reps see which channels deliver buyers vs tire-kickers — orthogonal
|
||||
* reps see which channels deliver buyers vs tire-kickers - orthogonal
|
||||
* to the existing "lead source attribution" chart which only counts
|
||||
* inbound volume.
|
||||
*/
|
||||
@@ -478,6 +481,108 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
||||
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
|
||||
(r) => r.title,
|
||||
),
|
||||
loadLabels(
|
||||
'residential_client',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: residentialClients.id, name: residentialClients.fullName })
|
||||
.from(residentialClients)
|
||||
.where(and(eq(residentialClients.portId, portId), inArray(residentialClients.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'residential_interest',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: residentialInterests.id,
|
||||
clientName: residentialClients.fullName,
|
||||
})
|
||||
.from(residentialInterests)
|
||||
.innerJoin(
|
||||
residentialClients,
|
||||
eq(residentialInterests.residentialClientId, residentialClients.id),
|
||||
)
|
||||
.where(
|
||||
and(eq(residentialInterests.portId, portId), inArray(residentialInterests.id, ids)),
|
||||
),
|
||||
(r) => r.clientName,
|
||||
),
|
||||
loadLabels(
|
||||
'berth_reservation',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: berthReservations.id,
|
||||
mooring: berths.mooringNumber,
|
||||
clientName: clients.fullName,
|
||||
})
|
||||
.from(berthReservations)
|
||||
.innerJoin(berths, eq(berthReservations.berthId, berths.id))
|
||||
.leftJoin(clients, eq(berthReservations.clientId, clients.id))
|
||||
.where(and(eq(berthReservations.portId, portId), inArray(berthReservations.id, ids))),
|
||||
(r) => `Berth ${r.mooring}${r.clientName ? ` · ${r.clientName}` : ''}`,
|
||||
),
|
||||
loadLabels(
|
||||
'payment',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: payments.id,
|
||||
clientName: clients.fullName,
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(interests, eq(payments.interestId, interests.id))
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(eq(payments.portId, portId), inArray(payments.id, ids))),
|
||||
(r) => `${r.clientName} · ${r.currency} ${r.amount}`,
|
||||
),
|
||||
// Notes resolve to their parent entity's name so the feed reads
|
||||
// "Client note on Matthew Ciaccio" rather than a UUID-prefix fallback
|
||||
// when the note itself has no human-readable identifier.
|
||||
loadLabels(
|
||||
'client_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: clientNotes.id, parent: clients.fullName })
|
||||
.from(clientNotes)
|
||||
.innerJoin(clients, eq(clientNotes.clientId, clients.id))
|
||||
.where(and(eq(clients.portId, portId), inArray(clientNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
loadLabels(
|
||||
'interest_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: interestNotes.id, parent: clients.fullName })
|
||||
.from(interestNotes)
|
||||
.innerJoin(interests, eq(interestNotes.interestId, interests.id))
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(eq(interests.portId, portId), inArray(interestNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
loadLabels(
|
||||
'yacht_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: yachtNotes.id, parent: yachts.name })
|
||||
.from(yachtNotes)
|
||||
.innerJoin(yachts, eq(yachtNotes.yachtId, yachts.id))
|
||||
.where(and(eq(yachts.portId, portId), inArray(yachtNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
loadLabels(
|
||||
'company_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: companyNotes.id, parent: companies.name })
|
||||
.from(companyNotes)
|
||||
.innerJoin(companies, eq(companyNotes.companyId, companies.id))
|
||||
.where(and(eq(companies.portId, portId), inArray(companyNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
]);
|
||||
|
||||
// Resolve user UUIDs that appear as the actor (auditLogs.userId) and
|
||||
|
||||
Reference in New Issue
Block a user