2026-05-21 23:32:21 +02:00
|
|
|
import { and, count, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
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
2026-05-23 00:52:59 +02:00
|
|
|
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';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { berths } from '@/lib/db/schema/berths';
|
2026-05-25 15:09:35 +02:00
|
|
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
2026-05-12 14:50:58 +02:00
|
|
|
import { invoices, expenses } from '@/lib/db/schema/financial';
|
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
2026-05-23 00:52:59 +02:00
|
|
|
import { payments } from '@/lib/db/schema/pipeline';
|
2026-05-12 14:50:58 +02:00
|
|
|
import { documents } from '@/lib/db/schema/documents';
|
|
|
|
|
import { reminders } from '@/lib/db/schema/operations';
|
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
2026-05-23 00:52:59 +02:00
|
|
|
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
2026-05-14 15:19:38 +02:00
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
2026-05-21 18:14:21 +02:00
|
|
|
import { userProfiles } from '@/lib/db/schema/users';
|
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
|
|
|
import { PIPELINE_STAGES, STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
2026-05-14 15:19:38 +02:00
|
|
|
import { convert as convertCurrency } from '@/lib/services/currency';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
const DEFAULT_PIPELINE_WEIGHTS: Record<string, number> = STAGE_WEIGHTS;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// ─── KPIs ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-21 23:32:21 +02:00
|
|
|
/**
|
|
|
|
|
* Pipeline KPIs. When `range` is supplied the pipeline-value calculation
|
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
2026-05-23 00:52:59 +02:00
|
|
|
* is scoped to interests whose `createdAt` falls inside the range - lets
|
2026-05-21 23:32:21 +02:00
|
|
|
* 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").
|
|
|
|
|
*/
|
|
|
|
|
export async function getKpis(portId: string, range?: { from: Date; to: Date } | null) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const [totalClientsRow] = await db
|
|
|
|
|
.select({ value: count() })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(eq(clients.portId, portId), isNull(clients.archivedAt)));
|
|
|
|
|
|
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
2026-05-23 00:52:59 +02:00
|
|
|
// Range filter - clamp to the interest's createdAt. Returns undefined
|
2026-05-21 23:32:21 +02:00
|
|
|
// when no range is provided so the existing all-time queries stay
|
|
|
|
|
// unaffected.
|
|
|
|
|
const rangeClause = range
|
|
|
|
|
? and(gte(interests.createdAt, range.from), lte(interests.createdAt, range.to))
|
|
|
|
|
: undefined;
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const [activeInterestsRow] = await db
|
|
|
|
|
.select({ value: count() })
|
|
|
|
|
.from(interests)
|
2026-05-21 23:32:21 +02:00
|
|
|
.where(
|
|
|
|
|
rangeClause ? and(activeInterestsWhere(portId), rangeClause) : activeInterestsWhere(portId),
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
// Pipeline value: SUM each berth's price ONCE regardless of how many
|
|
|
|
|
// active interests reference it. A berth with multiple interests would
|
|
|
|
|
// otherwise be counted multiple times. Reads the primary-berth link
|
2026-05-05 02:41:52 +02:00
|
|
|
// via interest_berths (plan §3.4).
|
2026-05-14 15:19:38 +02:00
|
|
|
//
|
|
|
|
|
// 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
|
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
2026-05-23 00:52:59 +02:00
|
|
|
// labeled the total as USD - a silent lie when a port priced any
|
2026-05-14 15:19:38 +02:00
|
|
|
// berth in a non-USD currency.
|
|
|
|
|
const [portRow] = await db
|
|
|
|
|
.select({ defaultCurrency: ports.defaultCurrency })
|
|
|
|
|
.from(ports)
|
|
|
|
|
.where(eq(ports.id, portId));
|
|
|
|
|
const targetCurrency = portRow?.defaultCurrency ?? 'USD';
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const pipelineRows = await db
|
2026-05-14 15:19:38 +02:00
|
|
|
.selectDistinct({
|
|
|
|
|
berthId: interestBerths.berthId,
|
|
|
|
|
price: berths.price,
|
|
|
|
|
priceCurrency: berths.priceCurrency,
|
|
|
|
|
})
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.from(interests)
|
2026-05-05 02:41:52 +02:00
|
|
|
.innerJoin(
|
|
|
|
|
interestBerths,
|
|
|
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
|
|
|
)
|
|
|
|
|
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
2026-05-21 23:32:21 +02:00
|
|
|
.where(
|
|
|
|
|
rangeClause ? and(activeInterestsWhere(portId), rangeClause) : activeInterestsWhere(portId),
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
let pipelineValue = 0;
|
|
|
|
|
for (const row of pipelineRows) {
|
|
|
|
|
if (!row.price) continue;
|
|
|
|
|
const amount = parseFloat(String(row.price));
|
|
|
|
|
if (!Number.isFinite(amount) || amount === 0) continue;
|
|
|
|
|
const sourceCurrency = (row.priceCurrency ?? targetCurrency).toUpperCase();
|
|
|
|
|
if (sourceCurrency === targetCurrency.toUpperCase()) {
|
|
|
|
|
pipelineValue += amount;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const converted = await convertCurrency(amount, sourceCurrency, targetCurrency);
|
|
|
|
|
if (converted) {
|
|
|
|
|
pipelineValue += converted.result;
|
|
|
|
|
} else {
|
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
2026-05-23 00:52:59 +02:00
|
|
|
// Missing rate - degrade to summing raw amount so the tile shows
|
2026-05-14 15:19:38 +02:00
|
|
|
// an approximate-but-recognizable number rather than swallowing
|
|
|
|
|
// the berth entirely. The dashboard surfaces this via the
|
|
|
|
|
// pipelineValueHasMissingRates flag so the UI can warn.
|
|
|
|
|
pipelineValue += amount;
|
|
|
|
|
}
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
// Occupancy rate: berths with `status='sold'` / total * 100. Per the
|
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
2026-05-23 00:52:59 +02:00
|
|
|
// 2026-05-14 decision, `under_offer` is NOT occupied - a reservation
|
2026-05-14 15:19:38 +02:00
|
|
|
// blocks the berth from sale to others but the berth is still
|
|
|
|
|
// technically available until the sale closes.
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const allBerthsRows = await db
|
|
|
|
|
.select({ status: berths.status })
|
|
|
|
|
.from(berths)
|
fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.
Changes:
- `archiveBerth(id, portId, { reason }, meta)` is the new canonical
soft-archive. Requires a reason (min 5 chars). Blocks when an
active interest still depends on the berth (forces the rep to
resolve the deal first). Audit-logs the old status + reason.
- `restoreBerth(...)` reverses it.
- DELETE route now accepts `{ reason }` and routes to archiveBerth.
- New POST /api/v1/berths/[id]/restore.
- `getBerthOptions` + dashboard occupancy / status-distribution
queries gain `isNull(berths.archivedAt)` so archived moorings
don't show up in pickers or skew metrics.
- Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
so import sites we haven't migrated still work — labeled @deprecated.
Verified live:
- DELETE w/o reason → 400 (validation)
- DELETE w/ "x" → 400 "Reason must be ≥ 5 characters"
- DELETE w/ proper reason → 204, row archived, reason persisted
- DELETE twice → 409 "Berth is already archived"
- POST /restore → 204, archived_at cleared
Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
|
|
|
// F5: archived berths excluded so retired moorings don't dilute denominator.
|
|
|
|
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const totalBerths = allBerthsRows.length;
|
2026-05-14 15:19:38 +02:00
|
|
|
const occupiedBerths = allBerthsRows.filter((b) => b.status === 'sold').length;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const occupancyRate = totalBerths > 0 ? (occupiedBerths / totalBerths) * 100 : 0;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalClients: totalClientsRow?.value ?? 0,
|
|
|
|
|
activeInterests: activeInterestsRow?.value ?? 0,
|
2026-05-14 15:19:38 +02:00
|
|
|
pipelineValue,
|
|
|
|
|
pipelineValueCurrency: targetCurrency,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
occupancyRate,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Pipeline Counts ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getPipelineCounts(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
stage: interests.pipelineStage,
|
|
|
|
|
count: sql<number>`count(*)::int`,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId))
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.groupBy(interests.pipelineStage);
|
|
|
|
|
|
|
|
|
|
const countsByStage = Object.fromEntries(rows.map((r) => [r.stage, r.count]));
|
|
|
|
|
|
|
|
|
|
return PIPELINE_STAGES.map((stage) => ({
|
|
|
|
|
stage,
|
|
|
|
|
count: countsByStage[stage] ?? 0,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Revenue Forecast ─────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-21 23:32:21 +02:00
|
|
|
export async function getRevenueForecast(portId: string, range?: { from: Date; to: Date } | null) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// Load weights from systemSettings
|
|
|
|
|
let weights: Record<string, number> = DEFAULT_PIPELINE_WEIGHTS;
|
|
|
|
|
let weightsSource: 'db' | 'default' = 'default';
|
|
|
|
|
|
|
|
|
|
const settingRow = await db.query.systemSettings.findFirst({
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (settingRow?.value) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = settingRow.value as Record<string, number>;
|
|
|
|
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
|
|
|
weights = parsed;
|
|
|
|
|
weightsSource = 'db';
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall through to defaults
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 22:57:01 +02:00
|
|
|
// Forecast excludes lost/cancelled - only currently-active or won-out
|
2026-05-05 02:41:52 +02:00
|
|
|
// interests should affect the weighted pipeline value. Reads the
|
|
|
|
|
// primary-berth link via interest_berths (plan §3.4).
|
2026-05-21 23:32:21 +02:00
|
|
|
const forecastRangeClause = range
|
|
|
|
|
? and(gte(interests.createdAt, range.from), lte(interests.createdAt, range.to))
|
|
|
|
|
: undefined;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const interestRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: interests.id,
|
|
|
|
|
pipelineStage: interests.pipelineStage,
|
|
|
|
|
berthPrice: berths.price,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
2026-05-05 02:41:52 +02:00
|
|
|
.innerJoin(
|
|
|
|
|
interestBerths,
|
|
|
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
|
|
|
)
|
|
|
|
|
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
2026-05-21 23:32:21 +02:00
|
|
|
.where(
|
|
|
|
|
forecastRangeClause
|
|
|
|
|
? and(activeInterestsWhere(portId), forecastRangeClause)
|
|
|
|
|
: activeInterestsWhere(portId),
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
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
2026-05-23 00:52:59 +02:00
|
|
|
// Build stageBreakdown - gross value, weighted value, per-stage weight,
|
2026-05-20 15:56:11 +02:00
|
|
|
// 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
|
|
|
|
|
// doesn't read as legitimate.
|
|
|
|
|
const stageMap: Record<
|
|
|
|
|
string,
|
|
|
|
|
{ count: number; grossValue: number; weightedValue: number; dealsMissingPrice: number }
|
|
|
|
|
> = {};
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
for (const row of interestRows) {
|
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
|
|
|
const stage = canonicalizeStage(row.pipelineStage);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const price = row.berthPrice ? parseFloat(String(row.berthPrice)) : 0;
|
|
|
|
|
const weight = weights[stage] ?? 0;
|
|
|
|
|
const weighted = price * weight;
|
|
|
|
|
|
|
|
|
|
if (!stageMap[stage]) {
|
2026-05-20 15:56:11 +02:00
|
|
|
stageMap[stage] = { count: 0, grossValue: 0, weightedValue: 0, dealsMissingPrice: 0 };
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
stageMap[stage]!.count += 1;
|
2026-05-20 15:56:11 +02:00
|
|
|
stageMap[stage]!.grossValue += price;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
stageMap[stage]!.weightedValue += weighted;
|
2026-05-20 15:56:11 +02:00
|
|
|
if (!(price > 0)) stageMap[stage]!.dealsMissingPrice += 1;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stageBreakdown = PIPELINE_STAGES.map((stage) => ({
|
|
|
|
|
stage,
|
|
|
|
|
count: stageMap[stage]?.count ?? 0,
|
2026-05-20 15:56:11 +02:00
|
|
|
grossValue: stageMap[stage]?.grossValue ?? 0,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
weightedValue: stageMap[stage]?.weightedValue ?? 0,
|
2026-05-20 15:56:11 +02:00
|
|
|
weight: weights[stage] ?? 0,
|
|
|
|
|
dealsMissingPrice: stageMap[stage]?.dealsMissingPrice ?? 0,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
2026-05-20 15:56:11 +02:00
|
|
|
const totalGrossValue = stageBreakdown.reduce((acc, s) => acc + s.grossValue, 0);
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
return {
|
2026-05-20 15:56:11 +02:00
|
|
|
totalGrossValue,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
totalWeightedValue,
|
|
|
|
|
stageBreakdown,
|
|
|
|
|
weightsSource,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:50:58 +02:00
|
|
|
// ─── Compact widget queries ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Berth status split for the donut widget. Returns counts plus the total
|
|
|
|
|
* so the chart can show "12 of 47 sold" alongside the segment percentage.
|
|
|
|
|
*/
|
|
|
|
|
export async function getBerthStatusDistribution(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({ status: berths.status, c: sql<number>`count(*)::int` })
|
|
|
|
|
.from(berths)
|
fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.
Changes:
- `archiveBerth(id, portId, { reason }, meta)` is the new canonical
soft-archive. Requires a reason (min 5 chars). Blocks when an
active interest still depends on the berth (forces the rep to
resolve the deal first). Audit-logs the old status + reason.
- `restoreBerth(...)` reverses it.
- DELETE route now accepts `{ reason }` and routes to archiveBerth.
- New POST /api/v1/berths/[id]/restore.
- `getBerthOptions` + dashboard occupancy / status-distribution
queries gain `isNull(berths.archivedAt)` so archived moorings
don't show up in pickers or skew metrics.
- Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
so import sites we haven't migrated still work — labeled @deprecated.
Verified live:
- DELETE w/o reason → 400 (validation)
- DELETE w/ "x" → 400 "Reason must be ≥ 5 characters"
- DELETE w/ proper reason → 204, row archived, reason persisted
- DELETE twice → 409 "Berth is already archived"
- POST /restore → 204, archived_at cleared
Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
|
|
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
2026-05-12 14:50:58 +02:00
|
|
|
.groupBy(berths.status);
|
|
|
|
|
|
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
|
for (const r of rows) counts[r.status] = r.c;
|
|
|
|
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
total,
|
|
|
|
|
available: counts['available'] ?? 0,
|
|
|
|
|
underOffer: counts['under_offer'] ?? 0,
|
|
|
|
|
sold: counts['sold'] ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
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
2026-05-23 00:52:59 +02:00
|
|
|
* Top 5 active interests closest to closing - ranked by pipeline stage
|
2026-05-12 14:50:58 +02:00
|
|
|
* (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.
|
|
|
|
|
*/
|
|
|
|
|
export async function getHotDeals(portId: string, limit = 5) {
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
// Stage rank: bigger = closer to closing. Mirrors the 7-stage pipeline
|
|
|
|
|
// shipped 2026-05-14 (pipeline-refactor wave). Nurturing is a holding
|
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
2026-05-23 00:52:59 +02:00
|
|
|
// pen below qualified - supply-constrained ports flip deals there when
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
// they can't progress. Won/lost/cancelled outcomes are filtered out via
|
|
|
|
|
// `outcome IS NULL` below, so they don't need a rank slot.
|
2026-05-12 14:50:58 +02:00
|
|
|
const rank = sql<number>`CASE ${interests.pipelineStage}
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
WHEN 'contract' THEN 7
|
|
|
|
|
WHEN 'deposit_paid' THEN 6
|
|
|
|
|
WHEN 'reservation' THEN 5
|
|
|
|
|
WHEN 'eoi' THEN 4
|
|
|
|
|
WHEN 'qualified' THEN 3
|
|
|
|
|
WHEN 'nurturing' THEN 2
|
|
|
|
|
WHEN 'enquiry' THEN 1
|
2026-05-12 14:50:58 +02:00
|
|
|
ELSE 0
|
|
|
|
|
END`;
|
|
|
|
|
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: interests.id,
|
|
|
|
|
stage: interests.pipelineStage,
|
|
|
|
|
clientName: clients.fullName,
|
|
|
|
|
mooring: berths.mooringNumber,
|
|
|
|
|
lastContact: interests.dateLastContact,
|
|
|
|
|
updatedAt: interests.updatedAt,
|
|
|
|
|
rank,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.innerJoin(clients, eq(interests.clientId, clients.id))
|
|
|
|
|
.leftJoin(
|
|
|
|
|
interestBerths,
|
|
|
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
|
|
|
)
|
|
|
|
|
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId))
|
2026-05-12 14:50:58 +02:00
|
|
|
.orderBy(desc(rank), desc(interests.updatedAt))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
stage: r.stage,
|
|
|
|
|
clientName: r.clientName,
|
|
|
|
|
mooringNumber: r.mooring,
|
|
|
|
|
lastContact: r.lastContact ? r.lastContact.toISOString() : null,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Source-conversion breakdown for the marketing widget. Returns per-
|
|
|
|
|
* source totals (active + won + lost) and a derived conversion rate so
|
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
2026-05-23 00:52:59 +02:00
|
|
|
* reps see which channels deliver buyers vs tire-kickers - orthogonal
|
2026-05-12 14:50:58 +02:00
|
|
|
* to the existing "lead source attribution" chart which only counts
|
|
|
|
|
* inbound volume.
|
|
|
|
|
*/
|
|
|
|
|
export async function getSourceConversion(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
source: interests.source,
|
|
|
|
|
total: sql<number>`count(*)::int`,
|
|
|
|
|
won: sql<number>`sum(case when ${interests.outcome} = 'won' then 1 else 0 end)::int`,
|
|
|
|
|
lost: sql<number>`sum(case when ${interests.outcome} = 'lost' then 1 else 0 end)::int`,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
|
|
|
|
.groupBy(interests.source);
|
|
|
|
|
|
|
|
|
|
return rows
|
|
|
|
|
.filter((r) => r.source)
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
source: r.source!,
|
|
|
|
|
total: r.total,
|
|
|
|
|
won: r.won,
|
|
|
|
|
lost: r.lost,
|
|
|
|
|
conversionRate: r.total > 0 ? r.won / r.total : 0,
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => b.total - a.total);
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Recent Activity ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getRecentActivity(portId: string, limit = 20) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: auditLogs.id,
|
|
|
|
|
action: auditLogs.action,
|
|
|
|
|
entityType: auditLogs.entityType,
|
|
|
|
|
entityId: auditLogs.entityId,
|
|
|
|
|
userId: auditLogs.userId,
|
2026-05-12 14:50:58 +02:00
|
|
|
fieldChanged: auditLogs.fieldChanged,
|
|
|
|
|
oldValue: auditLogs.oldValue,
|
|
|
|
|
newValue: auditLogs.newValue,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
metadata: auditLogs.metadata,
|
|
|
|
|
createdAt: auditLogs.createdAt,
|
|
|
|
|
})
|
|
|
|
|
.from(auditLogs)
|
|
|
|
|
.where(eq(auditLogs.portId, portId))
|
|
|
|
|
.orderBy(desc(auditLogs.createdAt))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
2026-05-12 14:50:58 +02:00
|
|
|
// Resolve a human label per row (client name, yacht name, invoice number,
|
|
|
|
|
// …). The dashboard widget previously rendered the bare UUID prefix which
|
|
|
|
|
// told reps nothing about which entity was touched. We batch one SELECT
|
|
|
|
|
// per entityType, capping at the row set's natural size (<= `limit`).
|
|
|
|
|
const byType = new Map<string, Set<string>>();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
if (!r.entityId) continue;
|
|
|
|
|
if (!byType.has(r.entityType)) byType.set(r.entityType, new Set());
|
|
|
|
|
byType.get(r.entityType)!.add(r.entityId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labels = new Map<string, string>(); // `${type}:${id}` → label
|
|
|
|
|
|
|
|
|
|
async function loadLabels<T extends { id: string }>(
|
|
|
|
|
type: string,
|
|
|
|
|
fetcher: (ids: string[]) => Promise<T[]>,
|
|
|
|
|
pick: (row: T) => string,
|
|
|
|
|
) {
|
|
|
|
|
const ids = Array.from(byType.get(type) ?? []);
|
|
|
|
|
if (ids.length === 0) return;
|
|
|
|
|
const fetched = await fetcher(ids);
|
|
|
|
|
for (const row of fetched) labels.set(`${type}:${row.id}`, pick(row));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
loadLabels(
|
|
|
|
|
'client',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: clients.id, name: clients.fullName })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(eq(clients.portId, portId), inArray(clients.id, ids))),
|
|
|
|
|
(r) => r.name,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'yacht',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: yachts.id, name: yachts.name })
|
|
|
|
|
.from(yachts)
|
|
|
|
|
.where(and(eq(yachts.portId, portId), inArray(yachts.id, ids))),
|
|
|
|
|
(r) => r.name,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'company',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: companies.id, name: companies.name })
|
|
|
|
|
.from(companies)
|
|
|
|
|
.where(and(eq(companies.portId, portId), inArray(companies.id, ids))),
|
|
|
|
|
(r) => r.name,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'interest',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: interests.id, clientName: clients.fullName })
|
|
|
|
|
.from(interests)
|
|
|
|
|
.innerJoin(clients, eq(interests.clientId, clients.id))
|
|
|
|
|
.where(and(eq(interests.portId, portId), inArray(interests.id, ids))),
|
|
|
|
|
(r) => r.clientName,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'berth',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: berths.id, mooring: berths.mooringNumber })
|
|
|
|
|
.from(berths)
|
|
|
|
|
.where(and(eq(berths.portId, portId), inArray(berths.id, ids))),
|
|
|
|
|
(r) => `Berth ${r.mooring}`,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'invoice',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: invoices.id, num: invoices.invoiceNumber })
|
|
|
|
|
.from(invoices)
|
|
|
|
|
.where(and(eq(invoices.portId, portId), inArray(invoices.id, ids))),
|
|
|
|
|
(r) => r.num,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'expense',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({
|
|
|
|
|
id: expenses.id,
|
|
|
|
|
desc: expenses.description,
|
|
|
|
|
vendor: expenses.establishmentName,
|
|
|
|
|
})
|
|
|
|
|
.from(expenses)
|
|
|
|
|
.where(and(eq(expenses.portId, portId), inArray(expenses.id, ids))),
|
|
|
|
|
(r) => r.desc ?? r.vendor ?? 'Expense',
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'document',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: documents.id, title: documents.title })
|
|
|
|
|
.from(documents)
|
|
|
|
|
.where(and(eq(documents.portId, portId), inArray(documents.id, ids))),
|
|
|
|
|
(r) => r.title,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'reminder',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: reminders.id, title: reminders.title })
|
|
|
|
|
.from(reminders)
|
|
|
|
|
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
|
|
|
|
|
(r) => r.title,
|
|
|
|
|
),
|
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
2026-05-23 00:52:59 +02:00
|
|
|
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(
|
2026-05-25 15:09:35 +02:00
|
|
|
'berth_tenancy',
|
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
2026-05-23 00:52:59 +02:00
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({
|
2026-05-25 15:09:35 +02:00
|
|
|
id: berthTenancies.id,
|
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
2026-05-23 00:52:59 +02:00
|
|
|
mooring: berths.mooringNumber,
|
|
|
|
|
clientName: clients.fullName,
|
|
|
|
|
})
|
2026-05-25 15:09:35 +02:00
|
|
|
.from(berthTenancies)
|
|
|
|
|
.innerJoin(berths, eq(berthTenancies.berthId, berths.id))
|
|
|
|
|
.leftJoin(clients, eq(berthTenancies.clientId, clients.id))
|
|
|
|
|
.where(and(eq(berthTenancies.portId, portId), inArray(berthTenancies.id, ids))),
|
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
2026-05-23 00:52:59 +02:00
|
|
|
(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}`,
|
|
|
|
|
),
|
2026-05-12 14:50:58 +02:00
|
|
|
]);
|
|
|
|
|
|
2026-05-21 18:14:21 +02:00
|
|
|
// Resolve user UUIDs that appear as the actor (auditLogs.userId) and
|
|
|
|
|
// as oldValue/newValue on user-FK diff rows (assignedTo, ownerId,
|
|
|
|
|
// reassignedTo, createdBy). Activity-feed audit-log rows previously
|
|
|
|
|
// rendered the raw UUID prefix, which was unreadable.
|
|
|
|
|
const USER_FK_FIELDS = new Set([
|
|
|
|
|
'assignedTo',
|
|
|
|
|
'ownerId',
|
|
|
|
|
'reassignedTo',
|
|
|
|
|
'createdBy',
|
|
|
|
|
'addedBy',
|
|
|
|
|
'changedBy',
|
|
|
|
|
'transferredBy',
|
|
|
|
|
]);
|
|
|
|
|
const userIds = new Set<string>();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
if (r.userId) userIds.add(r.userId);
|
|
|
|
|
if (r.fieldChanged && USER_FK_FIELDS.has(r.fieldChanged)) {
|
|
|
|
|
if (typeof r.oldValue === 'string') userIds.add(r.oldValue);
|
|
|
|
|
if (typeof r.newValue === 'string') userIds.add(r.newValue);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const userNames = new Map<string, string>();
|
|
|
|
|
if (userIds.size > 0) {
|
|
|
|
|
const profiles = await db
|
|
|
|
|
.select({
|
|
|
|
|
userId: userProfiles.userId,
|
|
|
|
|
displayName: userProfiles.displayName,
|
|
|
|
|
firstName: userProfiles.firstName,
|
|
|
|
|
lastName: userProfiles.lastName,
|
|
|
|
|
})
|
|
|
|
|
.from(userProfiles)
|
|
|
|
|
.where(inArray(userProfiles.userId, Array.from(userIds)));
|
|
|
|
|
for (const p of profiles) {
|
|
|
|
|
const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName;
|
|
|
|
|
userNames.set(p.userId, name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function resolveUser(id: unknown): unknown {
|
|
|
|
|
if (typeof id !== 'string') return id;
|
|
|
|
|
const name = userNames.get(id);
|
|
|
|
|
if (name) return name;
|
|
|
|
|
return `Unknown user (#${id.slice(0, 8)})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rows.map((r) => {
|
|
|
|
|
const isUserFk = r.fieldChanged && USER_FK_FIELDS.has(r.fieldChanged);
|
|
|
|
|
return {
|
|
|
|
|
...r,
|
|
|
|
|
label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
|
|
|
|
|
// Replace user UUIDs with display names; non-user-FK rows pass through.
|
|
|
|
|
oldValue: isUserFk ? resolveUser(r.oldValue) : r.oldValue,
|
|
|
|
|
newValue: isUserFk ? resolveUser(r.newValue) : r.newValue,
|
|
|
|
|
// Surfaces the actor's name to the renderer; original userId stays
|
|
|
|
|
// available for forensics / deep-link if a later UI needs it.
|
|
|
|
|
actorName: r.userId ? (userNames.get(r.userId) ?? null) : null,
|
|
|
|
|
};
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|