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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -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