10 Commits

Author SHA1 Message Date
Matt Ciaccio
8699f81879 chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00
Matt Ciaccio
d62822c284 fix(migration): NocoDB import safety + dedup helpers + lead-source backfill
migration-apply: residential client + interest inserts now wrap in
db.transaction so a partial failure can't leave an orphan client
row without its interest (or vice versa).

migration-transform: buildPlannedDocument returns null when there
are no signers so the apply pass doesn't try to send a Documenso
envelope without recipients. mapDocumentStatus gets an explicit
"Awaiting Further Details" branch that no longer auto-promotes via
stale sign-time fields. parseFlexibleDate handles ISO and DD-MM-YYYY
inputs uniformly.

backfill-legacy-lead-source: chunk UPDATE WHERE clause now
isNull(source) on top of the inArray match, so a re-run can't
overwrite a more accurate source written between batches.

Adds 235 lines of vitest coverage on migration-transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:56:18 +02:00
Matt Ciaccio
089f4a67a4 feat(receipts): upload guide page + scanner head-tag fix
Adds /invoices/upload-receipts as the dedicated explainer for the
mobile scanner PWA: install instructions for iOS/Android, direct
deep-link button, and a walkthrough of the scan -> verify -> save
flow. Sidebar entry replaces the old "Scan receipt" tab so the
desktop side picks up the install steps before sending users to
the mobile-only surface.

Scanner layout moves PWA manifest + apple-* meta tags from inline
JSX into Next.js's metadata/viewport exports so the App Router
doesn't try to render a second <head>, fixing a hydration error
that surfaced as two console warnings on the scan page.

Scanner shell gains a centered Port Nimara logo header so the
standalone PWA looks branded when launched from the home screen
without the dashboard chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:55:42 +02:00
Matt Ciaccio
77ad10ced1 feat(dashboard): custom date range + KPI port-hydration gate
DateRangePicker grows a "Custom range" mode (From/To inputs capped
at today, mutually-bounded so From <= To). dashboard-shell threads
the range through to /api/v1/analytics, which validates calendar
dates via ISO round-trip and enforces a 365-day cap as a backstop
against the occupancy timeline N+1.

KpiCards now gates its query on currentPortId so the early
unhydrated-store fetch can't cache a zeroed/error response and
display "-" until staleTime expires.

MyRemindersRail drops xl:h-full so the rail no longer stretches
past its grid row and overlaps ActivityFeed below.

useRealtimeInvalidation switches to partial-prefix queryKeys so a
realtime mutation invalidates every cached range bucket at once
instead of just the one currently visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:55 +02:00
Matt Ciaccio
e598cc0708 feat(layout): unified Inbox + UserMenu extraction
Replaces the topbar's separate AlertBell + NotificationBell with a
single Inbox popover that tabs between alerts and notifications.
NotificationBell keeps a popover-gate so it doesn't fire its list
fetch when Inbox is mounted alongside it.

Extracts the user dropdown into <UserMenu> and moves the port
switcher + role label + theme toggle into the sidebar footer so
the topbar can reclaim space for breadcrumbs and command search.

Adds dedicated Insights / Receipts nav sections in the sidebar
(scaffolds the website-analytics + upload-receipts entry points).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:06 +02:00
Matt Ciaccio
f5772ce318 feat(analytics): Umami integration with per-port admin settings
Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:53:06 +02:00
Matt Ciaccio
49d34e00c8 feat(website-intake): dual-write endpoint + migration chain repair
Adds website_submissions table + shared-secret POST endpoint so the
marketing site can dual-write inquiries alongside its NocoDB write.
Race-safe via INSERT ... ON CONFLICT, idempotent on submission_id,
refuses every request when WEBSITE_INTAKE_SECRET is unset. Also
repairs pre-existing 0020/0021/0022 prevId collision (renumbered +
journal re-sorted) so db:generate works again. 11 unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:52:33 +02:00
Matt Ciaccio
c612bbdfd9 fix(migration): legacy bare-mooring lookup + port-nimara berth backfill
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m12s
Build & Push Docker Images / build-and-push (push) Has been skipped
Two issues surfaced when applying the migration to dev:

1. Mooring number format mismatch
   The legacy NocoDB Interests table writes bare mooring strings
   ("D32", "B16", "A4"), but the new berths table (mirroring the
   NocoDB Berths snapshot) uses zero-padded dashed form ("D-32",
   "B-16", "A-04"). The interest→berth lookup missed every reference.

   migration-apply.ts now tries the literal value first, then falls
   back to a normalized form via `normalizeLegacyMooring(raw)`:
     "D32" -> "D-32"
     "A4"  -> "A-04"
     "E18" -> "E-18"
   Multi-mooring strings ("A3, D30") are left as-is so they surface in
   the warnings list for human review rather than silently picking one.

2. port-nimara only had the 12 hand-rolled seed berths, not the 117-
   berth NocoDB snapshot
   The mobile-foundation seed only places those 12 in port-nimara; the
   117-berth snapshot was added later but only seeded into Marina
   Azzurra (the secondary test port). Migrated interests reference
   moorings well beyond A-01..D-03, so most lookups failed.

   New scripts/load-berths-to-port-nimara.ts: idempotently loads any
   missing snapshot berths into port-nimara without disturbing the
   existing 12 (skips moorings that already exist). Run once;
   subsequent runs no-op.

Result of full migration run on dev:
  237 clients inserted (out of 245 total — 8 from prior seed)
  406 contacts, 52 addresses, 38 yachts, 252 interests
  27 interest→berth links resolved (only 13 source rows had a Berth
  field set in NocoDB to begin with — most legacy interests are early
  inquiries with no berth assignment)
  1 unresolved warning: source=277 has multi-mooring "A3, D30"

Verified in UI:
  /port-nimara/clients shows real names (John-michael Seelye, Reza
  Amjad, Etiennette Clamouze, …)
  /port-nimara/clients/<id> renders contacts (gmail.com addresses,
  E.164 phones), tab counts (Interests N, Yachts N), pipeline summary
  Dashboard: 245 clients, 266 active interests, $46.5M pipeline value
  Pipeline funnel chart now shows real distribution (180 Open, 45
  EOI Signed, dropoff through stages)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:05:11 +02:00
Matt Ciaccio
872c75f1a1 fix(safety): plug 3 EMAIL_REDIRECT_TO leaks + 10 unit tests + live smoke
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m10s
Build & Push Docker Images / build-and-push (push) Has been skipped
A pre-import audit caught three places where outbound comms could escape
even with EMAIL_REDIRECT_TO set. Plugged each, added unit tests so the
behavior can't silently regress, and shipped a live smoke script the
operator can run before any production data import.

Leak 1: email-compose.service.ts (per-account user composer)
  Built its own nodemailer transporter and called sendMail() directly,
  bypassing the centralized sendEmail()'s redirect. Now mirrors the same
  redirect: when EMAIL_REDIRECT_TO is set, "to" is rewritten, "cc" is
  dropped, and the subject is prefixed with "[redirected from <orig>]".

Leak 2: documenso-client.sendDocument()
  Tells Documenso to actually email the document. Recipient emails were
  rerouted at create-time (in pass-3) but a document created BEFORE the
  redirect was turned on could still trigger a real-client email. Now
  short-circuited when the redirect is set — returns the existing doc
  shape so downstream code doesn't see an unexpected null.

Leak 3: documenso-client.sendReminder()
  Same shape as sendDocument: emails a stored recipient address that may
  predate the redirect. Now short-circuits with a warn-level log.

Tests (tests/unit/comms-safety.test.ts):
  - createDocument rewrites recipients
  - generateDocumentFromTemplate rewrites both v1.13 formValues.*Email
    keys AND v2.x recipients[] arrays
  - sendDocument is short-circuited (no /send call)
  - sendReminder is short-circuited (no /remind call)
  - createDocument passes through unchanged when redirect unset
  - sendEmail rewrites to + subject for single recipient
  - sendEmail handles array of recipients (joined into subject prefix)
  - sendEmail passes through unchanged when redirect unset
  - Webhook worker reads process.env.EMAIL_REDIRECT_TO at dispatch time
    (no module-level caching that could miss a runtime flip)

Live smoke (scripts/smoke-test-redirect.ts):
  Monkey-patches nodemailer.createTransport, calls the real sendEmail()
  with a fake real-client address, verifies the captured outbound has
  the right "to" + subject. Run: `pnpm tsx scripts/smoke-test-redirect.ts`.
  Exits non-zero if the redirect failed for any reason — drop-in for a
  pre-deploy check.

Verification:
  pnpm exec tsc --noEmit       — 0 errors
  pnpm exec vitest run         — 936/936 (was 926, +10 new safety tests)
  pnpm tsx scripts/smoke-test-redirect.ts — PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:55:53 +02:00
Matt Ciaccio
c45aac551d feat(dedup): wire --apply path for NocoDB migration
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m12s
Build & Push Docker Images / build-and-push (push) Failing after 3m41s
Completes the migration script's apply phase, which was stubbed at
the P3 ship to defer until after the runtime surfaces (P2) and the
comms safety net were in place. Both prerequisites just landed on
main, so this unblocks the actual data import.

src/lib/dedup/migration-apply.ts (new):
  Idempotent apply driver. Walks the MigrationPlan, inserting clients,
  contacts, addresses, yacht stubs, and interests, threading every
  insert through the migration_source_links ledger so re-runs against
  the same data are safe. Per-entity transactions (not one giant
  transaction) so partial-failure resumption is just "run again."

  Per-entity behavior:
    - clients: idempotent on (source_system, source_id, target_type=client)
      across the entire dedup cluster — if any source row already maps
      to a client, reuse that record.
    - contacts: bulk insert, primary email + primary phone independent.
    - addresses: bulk insert, port_id required (schema enforces it),
      first address marked primary when multiple.
    - yachts: minimal stub when the legacy interest had a yachtName,
      currentOwnerType=client + currentOwnerId=migrated client. Linked
      via migration_source_links target_type=yacht.
    - interests: looks up berthId via mooring number, yachtId via the
      stub above. Carries Documenso ID forward when present.

  surnameToken from PlannedClient is dropped on insert (it's a dedup
  blocking-index artifact; runtime dedup re-derives from fullName).

scripts/migrate-from-nocodb.ts:
  - Removes the "not yet implemented" guard for --apply.
  - Adds EMAIL_REDIRECT_TO precondition gate: --apply errors out unless
    the env var is set, OR --unsafe-skip-redirect-check is also passed
    (production cutover only). Refers to docs/operations/outbound-comms-safety.md.
  - Re-fetches NocoDB at apply time (rather than reading a saved report
    dir) so the data is always fresh. Re-running is safe via the
    idempotency ledger.
  - Resolves target port via --port-slug (or first port if omitted).
  - Generates a UUID applyId tagged on every link, which pairs with a
    future --rollback flag.
  - Apply summary prints inserted/skipped counts per entity type plus
    the first 20 warnings.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.
The actual end-to-end run requires NOCODB_URL + NOCODB_TOKEN in .env
which aren't configured in this checkout; that's the operator's next
step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:53:04 +02:00
282 changed files with 16740 additions and 1133 deletions

View File

@@ -0,0 +1,135 @@
/**
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
*
* Why this exists: the legacy NocoDB Interests table left the `Source`
* column null for ~95 % of rows. The migration mapped null → null, so the
* Lead Source Attribution chart shows them as "Unspecified". Per the
* operator's best knowledge, almost all of those legacy rows came in
* through the website (web form / portal) — the few that didn't are the
* ones that already carry an explicit `Source` value (Form / portal /
* External). Defaulting null → 'website' is therefore the closest
* truth we can reconstruct without per-row sales notes review.
*
* Idempotent: only updates rows where `source IS NULL` AND the row has a
* `migration_source_links` entry tying it back to the legacy NocoDB import,
* so net-new manually-created interests with null source aren't touched.
*
* Usage:
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
*/
import 'dotenv/config';
import { eq, and, isNull, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { interests } from '@/lib/db/schema/interests';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
interface CliArgs {
portSlug: string | null;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { portSlug: null, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--dry-run') args.dryRun = true;
else if (a === '-h' || a === '--help') {
console.log(
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
);
process.exit(0);
}
}
if (!args.portSlug) {
console.error('Missing required --port-slug');
process.exit(1);
}
return args;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const [port] = await db
.select({ id: ports.id, name: ports.name })
.from(ports)
.where(eq(ports.slug, args.portSlug!))
.limit(1);
if (!port) {
console.error(`No port found with slug "${args.portSlug}"`);
process.exit(1);
}
console.log(`[backfill] target: ${port.name} (${port.id})`);
// Pull every interest id this port owns that has a NULL source.
const candidateInterests = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
if (candidateInterests.length === 0) {
console.log('Nothing to backfill.');
return;
}
// Filter to ONLY those that came in via the legacy migration — preserves
// null on net-new rows where the operator hasn't picked a source yet.
const candidateIds = candidateInterests.map((r) => r.id);
const legacyLinks = await db
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
eq(migrationSourceLinks.targetEntityType, 'interest'),
inArray(migrationSourceLinks.targetEntityId, candidateIds),
),
);
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
console.log(
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
);
console.log(
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
);
if (args.dryRun) {
console.log('[backfill] --dry-run set; no writes.');
return;
}
if (toUpdate.length === 0) {
console.log('Nothing to write.');
return;
}
// Update in chunks of 500 to keep query size sane.
const CHUNK = 500;
let updated = 0;
for (let i = 0; i < toUpdate.length; i += CHUNK) {
const chunk = toUpdate.slice(i, i + CHUNK);
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
// a concurrent process that set source on one of these rows
// between SELECT and UPDATE doesn't get its value clobbered.
const result = await db
.update(interests)
.set({ source: 'website' })
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
.returning({ id: interests.id });
updated += result.length;
}
console.log(`[backfill] updated ${updated} rows.`);
}
main().catch((err) => {
console.error('FATAL', err);
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
/**
* One-shot: load the 117-berth NocoDB snapshot into the port-nimara
* port, skipping any moorings that already exist.
*
* The original seed only seeded 12 hand-rolled berths into port-nimara
* (A-01..D-03), but the migration's interest rows reference moorings
* across A-01..E-18. This loads the full set so interest→berth links
* resolve cleanly on the next migration run.
*/
import 'dotenv/config';
import { eq, and, sql, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { berths } from '@/lib/db/schema/berths';
import berthSnapshot from '@/lib/db/seed-data/berths.json';
interface SnapshotBerth {
mooringNumber: string;
area: string;
status: 'available' | 'under_offer' | 'sold';
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
lengthM: number | null;
widthM: number | null;
draftM: number | null;
widthIsMinimum: boolean;
nominalBoatSize: number | null;
nominalBoatSizeM: number | null;
waterDepth: number | null;
waterDepthM: number | null;
waterDepthIsMinimum: boolean;
sidePontoon: string | null;
powerCapacity: number | null;
voltage: number | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: number | null;
bowFacing: string | null;
berthApproved: boolean;
statusOverrideMode: string | null;
}
async function main() {
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, 'port-nimara'))
.limit(1);
if (!port) throw new Error('port-nimara not found');
const snapshot = berthSnapshot as unknown as SnapshotBerth[];
// Existing moorings — skip these.
const existingRows = await db
.select({ mooringNumber: berths.mooringNumber })
.from(berths)
.where(eq(berths.portId, port.id));
const existingMoorings = new Set(existingRows.map((r) => r.mooringNumber));
const toInsert = snapshot.filter((b) => !existingMoorings.has(b.mooringNumber));
console.log(
`Snapshot: ${snapshot.length} berths, existing in port-nimara: ${existingRows.length}, to insert: ${toInsert.length}`,
);
if (toInsert.length === 0) {
console.log('Nothing to do.');
return;
}
const inserted = await db
.insert(berths)
.values(
toInsert.map((b) => ({
portId: port.id,
mooringNumber: b.mooringNumber,
area: b.area,
status: b.status,
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
widthFt: b.widthFt != null ? String(b.widthFt) : null,
draftFt: b.draftFt != null ? String(b.draftFt) : null,
lengthM: b.lengthM != null ? String(b.lengthM) : null,
widthM: b.widthM != null ? String(b.widthM) : null,
draftM: b.draftM != null ? String(b.draftM) : null,
widthIsMinimum: b.widthIsMinimum,
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
waterDepthIsMinimum: b.waterDepthIsMinimum,
sidePontoon: b.sidePontoon,
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
voltage: b.voltage != null ? String(b.voltage) : null,
mooringType: b.mooringType,
cleatType: b.cleatType,
cleatCapacity: b.cleatCapacity,
bollardType: b.bollardType,
bollardCapacity: b.bollardCapacity,
access: b.access,
price: b.price != null ? String(b.price) : null,
priceCurrency: 'USD',
bowFacing: b.bowFacing,
berthApproved: b.berthApproved,
statusOverrideMode: b.statusOverrideMode,
tenureType: 'permanent' as const,
})),
)
.returning({ id: berths.id, mooringNumber: berths.mooringNumber });
console.log(`Inserted ${inserted.length} berths.`);
// Suppress unused-import warning if eslint is strict.
void and;
void sql;
void inArray;
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -7,21 +7,30 @@
* Pulls the live NocoDB base, runs the transform + dedup pipeline, * Pulls the live NocoDB base, runs the transform + dedup pipeline,
* writes a report to .migration/<timestamp>/. NO database writes. * writes a report to .migration/<timestamp>/. NO database writes.
* *
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug harbor-royale * pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
* Same, but tags the planned writes with the named port (matters for * Same, but tags the planned writes with the named port (matters for
* the apply phase — every client/interest belongs to one port). * the apply phase — every client/interest belongs to one port).
* *
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/ * pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
* [Not yet implemented — apply phase comes in a follow-up PR.] * Re-fetches NocoDB, re-transforms, then writes the planned rows
* into the target port via the idempotent `migration_source_links`
* ledger. Re-runs are safe — already-imported source IDs are skipped.
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
* `--unsafe-skip-redirect-check` is also passed.
* *
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9. * Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
*/ */
import 'dotenv/config'; import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { applyPlan } from '@/lib/dedup/migration-apply';
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source'; import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
import { transformSnapshot } from '@/lib/dedup/migration-transform'; import { transformSnapshot } from '@/lib/dedup/migration-transform';
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report'; import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
@@ -31,6 +40,7 @@ interface CliArgs {
apply: boolean; apply: boolean;
portSlug: string | null; portSlug: string | null;
reportDir: string | null; reportDir: string | null;
unsafeSkipRedirectCheck: boolean;
} }
function parseArgs(argv: string[]): CliArgs { function parseArgs(argv: string[]): CliArgs {
@@ -39,6 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
apply: false, apply: false,
portSlug: null, portSlug: null,
reportDir: null, reportDir: null,
unsafeSkipRedirectCheck: false,
}; };
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!; const a = argv[i]!;
@@ -46,6 +57,7 @@ function parseArgs(argv: string[]): CliArgs {
else if (a === '--apply') args.apply = true; else if (a === '--apply') args.apply = true;
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null; else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--report') args.reportDir = argv[++i] ?? null; else if (a === '--report') args.reportDir = argv[++i] ?? null;
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
else if (a === '-h' || a === '--help') { else if (a === '-h' || a === '--help') {
printHelp(); printHelp();
process.exit(0); process.exit(0);
@@ -64,20 +76,50 @@ function printHelp(): void {
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/. Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
No database writes. No database writes.
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/ pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
Apply phase. (Not yet implemented.) Re-fetches NocoDB, re-transforms, writes via migration_source_links
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
(unless --unsafe-skip-redirect-check is also passed).
Flags: Flags:
--dry-run Read NocoDB, write report only. --dry-run Read NocoDB, write report only.
--apply Actually write to the new DB. (Not yet supported.) --apply Actually write rows to the DB.
--port-slug <slug> Port slug to attach to all imported entities. --port-slug <slug> Port slug to attach to all imported
Defaults to the first available port if omitted. entities. Defaults to the first
--report <dir> Path to a previously-generated report dir available port if omitted.
(only used by --apply). --report <dir> Path to a previously-generated report
-h, --help Show this help. dir (only used by --apply).
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
check. Only use in production cutover.
-h, --help Show this help.
`); `);
} }
/**
* Resolve the target port: use the slug if provided, otherwise the first
* port found. Errors out cleanly if the slug doesn't match any port.
*/
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
if (slug) {
const [p] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, slug))
.limit(1);
if (!p) {
console.error(`No port found with slug "${slug}".`);
process.exit(1);
}
return { id: p.id, slug: p.slug };
}
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
if (!first) {
console.error('No ports exist in the target DB. Seed at least one port before applying.');
process.exit(1);
}
return { id: first.id, slug: first.slug };
}
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
@@ -87,13 +129,21 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
if (args.apply) { // Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.'); // operator explicitly opts out (production cutover).
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.'); if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
console.error(
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
);
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
console.error(
'If you are running the production cutover and have read that doc, add ' +
'--unsafe-skip-redirect-check to override.',
);
process.exit(2); process.exit(2);
} }
// ── Dry-run path ─────────────────────────────────────────────────────────── // ── Fetch + transform (shared by dry-run and apply) ──────────────────────
console.log('[migrate] Loading NocoDB config…'); console.log('[migrate] Loading NocoDB config…');
const config = loadNocoDbConfig(); const config = loadNocoDbConfig();
@@ -110,8 +160,7 @@ async function main(): Promise<void> {
console.log('[migrate] Running transform + dedup pipeline…'); console.log('[migrate] Running transform + dedup pipeline…');
const plan = transformSnapshot(snapshot); const plan = transformSnapshot(snapshot);
// Resolve output paths relative to the worktree root (the script itself // Resolve output paths relative to the worktree root.
// lives in scripts/; we want the .migration dir at the repo root).
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
const generatedAt = new Date().toISOString(); const generatedAt = new Date().toISOString();
@@ -120,7 +169,7 @@ async function main(): Promise<void> {
console.log(`[migrate] Writing report to ${paths.rootDir}`); console.log(`[migrate] Writing report to ${paths.rootDir}`);
await writeReport(paths, plan, generatedAt); await writeReport(paths, plan, generatedAt);
// ── Console summary ────────────────────────────────────────────────────── // ── Plan summary ─────────────────────────────────────────────────────────
const s = plan.stats; const s = plan.stats;
console.log(''); console.log('');
console.log('=== Migration Plan Summary ==='); console.log('=== Migration Plan Summary ===');
@@ -129,12 +178,70 @@ async function main(): Promise<void> {
); );
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`); console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`); console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
console.log(
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
);
console.log(
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
);
console.log( console.log(
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`, ` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
); );
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`); console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
console.log(''); console.log('');
console.log(` Full report: ${paths.summaryPath}`); console.log(` Full report: ${paths.summaryPath}`);
if (args.dryRun) {
console.log('');
console.log('Dry-run complete. Re-run with --apply to write rows.');
return;
}
// ── Apply path ───────────────────────────────────────────────────────────
const port = await resolvePort(args.portSlug);
const applyId = randomUUID();
console.log('');
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
console.log(`[migrate] Apply id: ${applyId}`);
console.log('[migrate] Inserting…');
const applyStart = Date.now();
const result = await applyPlan(plan, { port, applyId });
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
console.log('');
console.log('=== Apply Result ===');
console.log(` Time: ${applyElapsed}s`);
console.log(
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
);
console.log(` Contacts: ${result.contactsInserted} inserted`);
console.log(` Addresses: ${result.addressesInserted} inserted`);
console.log(` Yachts: ${result.yachtsInserted} inserted`);
console.log(
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
);
console.log(
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
);
console.log(` Signers: ${result.documentSignersInserted} inserted`);
console.log(
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
);
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
if (result.warnings.length > 0) {
console.log('');
console.log('Warnings:');
for (const w of result.warnings.slice(0, 20)) {
console.log(` - ${w}`);
}
if (result.warnings.length > 20) {
console.log(`${result.warnings.length - 20} more`);
}
}
console.log(''); console.log('');
} }

View File

@@ -0,0 +1,108 @@
/**
* Live smoke test for EMAIL_REDIRECT_TO.
*
* Actually calls `sendEmail()` (the centralized helper used by every
* outbound email path in the app) with a fake real-client address. The
* SMTP transporter is monkey-patched to capture the message instead of
* actually delivering it, so this is safe to run anywhere.
*
* Prints the captured `to` + `subject` so the operator can see with their
* own eyes that the redirect happened. Exits non-zero if the redirect
* failed for any reason.
*
* Usage:
* pnpm tsx scripts/smoke-test-redirect.ts
*/
import 'dotenv/config';
async function main() {
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
if (!expectedRedirect) {
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
process.exit(1);
}
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
console.log('');
// Monkey-patch nodemailer's createTransport so we capture the call
// without actually delivering. This is the same pattern the unit
// tests use, but at the live import-time level so we're testing the
// exact code path that runs in production.
const nodemailer = await import('nodemailer');
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
const originalCreateTransport = nodemailer.default.createTransport;
// @ts-expect-error monkey-patch
nodemailer.default.createTransport = () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendMail: async (msg: any) => {
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
},
});
// Now import sendEmail (gets the patched transporter).
const { sendEmail } = await import('@/lib/email');
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
const realSubject = 'Important: Your contract is ready';
console.log('[smoke] calling sendEmail(...) with:');
console.log(` to: ${realClientEmail}`);
console.log(` subject: "${realSubject}"`);
console.log('');
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
// Restore the original transport (be a good citizen).
// @ts-expect-error monkey-patch
nodemailer.default.createTransport = originalCreateTransport;
console.log('[smoke] captured outbound message:');
console.log(` to: ${captured[0]?.to}`);
console.log(` subject: "${captured[0]?.subject}"`);
console.log(` from: ${captured[0]?.from}`);
console.log('');
// Assertions
let pass = true;
if (captured.length !== 1) {
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
pass = false;
}
if (captured[0]?.to !== expectedRedirect) {
console.error(
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
);
pass = false;
}
if (
typeof captured[0]?.subject !== 'string' ||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
) {
console.error(
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
);
pass = false;
}
if (pass) {
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
console.log(
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
);
process.exit(0);
} else {
console.error('');
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
process.exit(1);
}
}
main().catch((err) => {
console.error('FATAL:', err);
process.exit(1);
});

View File

@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
label: 'Default signature (HTML)', label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.', description: 'Appended to the bottom of system-generated emails.',
type: 'html', type: 'html',
placeholder: '<p><br>The Port Nimara team</p>', placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '', defaultValue: '',
}, },
{ {
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
{ {
key: 'smtp_pass_override', key: 'smtp_pass_override',
label: 'SMTP password override', label: 'SMTP password override',
description: 'Optional. Stored in plain text only set when overriding env credentials.', description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password', type: 'password',
defaultValue: '', defaultValue: '',
}, },

View File

@@ -18,6 +18,7 @@ import {
Users, Users,
UsersRound, UsersRound,
Webhook, Webhook,
Globe,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -209,6 +210,12 @@ const GROUPS: AdminGroup[] = [
description: 'Configure the AI provider used by the mobile receipt scanner.', description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText, icon: ScrollText,
}, },
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
},
], ],
}, },
]; ];

View File

@@ -0,0 +1,74 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'string',
placeholder: 'admin',
defaultValue: '',
},
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_website_id',
label: 'Website ID',
description:
'UUID of this ports website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
export default function WebsiteAnalyticsSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Website analytics (Umami)"
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
/>
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
</div>
);
}

View File

@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
/** /**
* Route-level loading UI for the client detail page. Renders while the * Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps * server component resolves the session and the client component bootstraps
* its initial query replaces the previous empty-header flash on direct * its initial query - replaces the previous empty-header flash on direct
* URL visits. * URL visits.
*/ */
export default function Loading() { export default function Loading() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header strip title, badges, action buttons */} {/* Header strip - title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3"> <div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" /> <Skeleton className="h-7 w-56" />

View File

@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
}, [setChrome]); }, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`, // When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} Berth {n}" in // fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill. // the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{ const { data: prefilledInterest } = useQuery<{
data: { data: {
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header desktop only; mobile gets the title from the topbar */} {/* Header - desktop only; mobile gets the title from the topbar */}
<div className="hidden sm:flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
{prefilledInterest?.data {prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${ ? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber prefilledInterest.data.berthMooringNumber
? ` Berth ${prefilledInterest.data.berthMooringNumber}` ? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
: '' : ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".` }. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'} : 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
export const metadata: Metadata = {
title: 'How to upload receipts',
};
export default async function UploadReceiptsPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell';
export const metadata: Metadata = {
title: 'Website analytics',
};
export default function WebsiteAnalyticsPage() {
return <WebsiteAnalyticsShell />;
}

View File

@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>
<RealtimeToasts /> <RealtimeToasts />
{/* Desktop shell hidden by CSS on mobile */} {/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background"> <div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
portRoles={portRoles} portRoles={portRoles}
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
name: profile?.displayName ?? session.user.name ?? session.user.email, name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email, email: session.user.email,
}} }}
ports={ports}
/> />
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar <Topbar
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email, email: session.user.email,
}} }}
/> />
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main> <main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
{children}
</main>
</div> </div>
</div> </div>
{/* Mobile shell hidden by CSS on desktop */} {/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout> <MobileLayout>{children}</MobileLayout>
</SocketProvider> </SocketProvider>
</PermissionsProvider> </PermissionsProvider>

View File

@@ -12,14 +12,10 @@ export const metadata: Metadata = {
}, },
}; };
export default async function PortalLayout({ export default async function PortalLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
// This layout wraps all portal routes including login/verify // This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt // We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down login/verify pages handle their own // to get the session and pass it down - login/verify pages handle their own
// redirect logic independently. // redirect logic independently.
const session = await getPortalSession().catch(() => null); const session = await getPortalSession().catch(() => null);
@@ -42,17 +38,11 @@ export default async function PortalLayout({
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{session && ( {session && (
<> <>
<PortalHeader <PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalNav /> <PortalNav />
</> </>
)} )}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}> <main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
{children}
</main>
</div> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
<PasswordSetForm <PasswordSetForm
endpoint="/api/portal/auth/activate" endpoint="/api/portal/auth/activate"
title="Activate your account" title="Activate your account"
description="Welcome choose a password to finish setting up your client portal account." description="Welcome - choose a password to finish setting up your client portal account."
successTitle="Account activated" successTitle="Account activated"
successDescription="You can now sign in with your new password." successDescription="You can now sign in with your new password."
submitLabel="Activate account" submitLabel="Activate account"

View File

@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { try {
// Always returns 200 caller never sees whether email exists. // Always returns 200 - caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', { await fetch('/api/portal/auth/forgot-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
<span className="font-medium text-gray-900">General Interest</span> <span className="font-medium text-gray-900">General Interest</span>
)} )}
{interest.berthArea && ( {interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span> <span className="text-sm text-gray-400">- {interest.berthArea}</span>
)} )}
</div> </div>
{interest.leadCategory && ( {interest.leadCategory && (

View File

@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span> <span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && ( {r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span> <span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
)} )}
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">

View File

@@ -1,20 +1,51 @@
import type { Metadata, Viewport } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports'; import { ports as portsTable } from '@/lib/db/schema/ports';
import { QueryProvider } from '@/providers/query-provider'; import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider'; import { PortProvider } from '@/providers/port-provider';
import { eq } from 'drizzle-orm';
/** /**
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no * Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
* topbar the scanner is its own contained surface. Adds the PWA * topbar - the scanner is its own contained surface. PWA manifest +
* manifest link + theme color so iOS/Android pick up "Add to Home * iOS web-app meta tags are emitted via Next.js's metadata/viewport
* Screen". Auth check matches the dashboard layout so unauthorized * exports so React doesn't try to render a second `<head>` mid-tree
* users still bounce to /login. * (which throws hydration errors in the App Router). Auth check
* matches the dashboard layout so unauthorized users still bounce.
*/ */
export async function generateMetadata({
params,
}: {
params: Promise<{ portSlug: string }>;
}): Promise<Metadata> {
const { portSlug } = await params;
return {
manifest: `/${portSlug}/scan/manifest.webmanifest`,
appleWebApp: {
capable: true,
title: 'PN Scanner',
statusBarStyle: 'default',
},
other: {
// Android/Chrome equivalent of the apple-* meta. metadata.appleWebApp
// covers iOS only; this preserves the existing PWA hint for Chrome.
'mobile-web-app-capable': 'yes',
},
};
}
export const viewport: Viewport = {
themeColor: '#3a7bc8',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default async function ScannerLayout({ export default async function ScannerLayout({
children, children,
params, params,
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
return ( return (
<QueryProvider> <QueryProvider>
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}> <PortProvider ports={[port]} defaultPortId={port.id}>
<head>
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
<meta name="theme-color" content="#3a7bc8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<div className="min-h-[100dvh] bg-background">{children}</div> <div className="min-h-[100dvh] bg-background">{children}</div>
</PortProvider> </PortProvider>
</QueryProvider> </QueryProvider>

View File

@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
const portName = port?.name ?? 'Port Nimara'; const portName = port?.name ?? 'Port Nimara';
const manifest = { const manifest = {
name: `${portName} Scanner`, name: `${portName} - Scanner`,
short_name: 'Scanner', short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`, description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`, start_url: `/${portSlug}/scan`,

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell'; import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt Port Nimara', title: 'Scan receipt - Port Nimara',
}; };
export default function ScanPage() { export default function ScanPage() {

View File

@@ -1,11 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
/** /**
* Liveness probe confirms the Next.js process is responding. * Liveness probe - confirms the Next.js process is responding.
* *
* Returns 200 unconditionally; if the process is wedged or has crashed * Returns 200 unconditionally; if the process is wedged or has crashed
* the request never lands here at all. Do NOT include database/Redis/MinIO * the request never lands here at all. Do NOT include database/Redis/MinIO
* checks in this endpoint a transient downstream blip should drop the * checks in this endpoint - a transient downstream blip should drop the
* pod from the load balancer (readiness), not restart the pod (liveness). * pod from the load balancer (readiness), not restart the pod (liveness).
* *
* For deep dependency checks, hit `/api/ready` instead. * For deep dependency checks, hit `/api/ready` instead.

View File

@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
// Keep the helper aligned with that. // Keep the helper aligned with that.
type Tx = typeof db; type Tx = typeof db;
// POST /api/public/interests unauthenticated public interest registration. // POST /api/public/interests - unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company + // Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction. // membership, all inside a single transaction.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest'; const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided). Read-only lookup safe // Resolve berth by mooring number (if provided). Read-only lookup - safe
// to do outside the transaction. // to do outside the transaction.
let berthId: string | null = null; let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null; let resolvedMooringNumber: string | null = data.mooringNumber ?? null;

View File

@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
} }
/** /**
* POST /api/public/residential-inquiries unauthenticated entry point for * POST /api/public/residential-inquiries - unauthenticated entry point for
* the public website's residential interest form. Creates a * the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a * `residential_clients` row and an opening `residential_interests` row in a
* single transaction. * single transaction.
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId }); emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId }); emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking failures shouldn't 500 the // Send notification emails (non-blocking - failures shouldn't 500 the
// public form). // public form).
void sendResidentialNotifications({ void sendResidentialNotifications({
portId, portId,
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
}); });
await sendEmail(data.email, confirmation.subject, confirmation.html); await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert pull recipients from system_settings if configured; // Sales-team alert - pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available. // fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({ const recipientsRow = await db.query.systemSettings.findFirst({
where: and( where: and(

View File

@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
/**
* POST /api/public/website-inquiries
*
* Capture endpoint for the marketing website's dual-write. The website
* server (`/server/api/register.ts`, `/server/api/contact.ts`) calls this
* AFTER its existing NocoDB write succeeds, sending the same payload as a
* server-to-server fire-and-forget POST. The CRM stores the raw payload
* in `website_submissions` for later analysis / promotion to entities.
*
* Auth: shared-secret in `X-Webhook-Secret` header, timing-safe compared
* against `WEBSITE_INTAKE_SECRET`. If the env var is unset on this
* instance, the endpoint refuses every request with 503 - the correct
* posture for dev/staging that hasn't been wired up yet.
*
* Idempotency: payload carries a `submission_id` UUID. The unique index
* on `website_submissions.submission_id` makes redelivery a no-op; the
* handler returns 200 + the existing record's id instead of erroring.
*
* No emails / no `interests` rows are created here. The endpoint's job is
* pure data capture. A separate "promote" step (future) will turn captured
* submissions into proper `clients` + `interests` rows once we trust the
* pipeline.
*/
const SubmissionSchema = z.object({
submission_id: z.string().uuid(),
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']),
payload: z.record(z.unknown()),
legacy_nocodb_id: z.string().optional(),
/** Defaults to port-nimara since that's currently the only port with a
* public marketing site. Future ports can override per-submission. */
port_slug: z.string().default('port-nimara'),
});
function verifySecret(header: string | null): boolean {
const expected = env.WEBSITE_INTAKE_SECRET;
if (!expected) return false;
if (!header) return false;
// Timing-safe compare requires equal-length buffers; pad to whichever is
// longer so an early-exit on length mismatch can't leak the secret length.
const a = Buffer.from(header);
const b = Buffer.from(expected);
const pad = Buffer.alloc(Math.max(a.length, b.length));
const aPad = Buffer.concat([a, pad]).subarray(0, pad.length);
const bPad = Buffer.concat([b, pad]).subarray(0, pad.length);
return timingSafeEqual(aPad, bPad) && a.length === b.length;
}
export async function POST(req: NextRequest) {
// Refuse outright if the CRM hasn't been wired up - safer than letting
// unauthenticated traffic in just because the env var was forgotten.
if (!env.WEBSITE_INTAKE_SECRET) {
return NextResponse.json(
{ error: 'Website intake is not configured on this server.' },
{ status: 503 },
);
}
// Auth gate - shared secret in header, timing-safe compare.
const secretHeader = req.headers.get('x-webhook-secret');
if (!verifySecret(secretHeader)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limit. All website-side traffic shares the website's egress IP,
// so we use a dedicated bucket sized to accommodate normal traffic
// (500/hr) rather than the 5/hr publicForm bucket meant for individual
// human submissions. The shared-secret header is the real abuse
// boundary; this limiter is just a backstop if the secret ever leaks.
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
const rl = await checkRateLimit(ip, rateLimiters.websiteIntake);
if (!rl.allowed) {
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
);
}
// Parse + validate body. Reject anything that doesn't conform — the
// website is a known caller; a malformed payload signals tampering.
let parsed;
try {
const body = await req.json();
parsed = SubmissionSchema.parse(body);
} catch (err) {
return NextResponse.json(
{ error: 'Invalid payload', details: err instanceof Error ? err.message : 'parse error' },
{ status: 400 },
);
}
// Resolve port. We require the slug to exist; can't capture submissions
// for a port the CRM doesn't know about.
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, parsed.port_slug))
.limit(1);
if (!port) {
// Don't echo the input slug back in the error - generic message is
// sufficient and avoids the input-reflection pattern that complicates
// log-injection / audit reviews. The slug is logged server-side
// for debugging.
logger.warn(
{ portSlug: parsed.port_slug, submissionId: parsed.submission_id },
'website-inquiry rejected: unknown port',
);
return NextResponse.json({ error: 'Unknown port' }, { status: 400 });
}
// Idempotent insert. Two parallel requests carrying the same submission_id
// could both pass any pre-check, so we don't pre-check at all - the unique
// index on submission_id is the source of truth, and `onConflictDoNothing`
// keeps the second request's INSERT from raising 23505. When the conflict
// hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup.
const insertResult = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId: parsed.submission_id,
kind: parsed.kind,
payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (insertResult[0]) {
logger.info(
{
submissionId: parsed.submission_id,
kind: parsed.kind,
portSlug: parsed.port_slug,
legacyNocodbId: parsed.legacy_nocodb_id,
},
'website inquiry captured',
);
return NextResponse.json({ id: insertResult[0].id, deduped: false });
}
// Conflict path: row already exists. Fetch its id so the response shape
// stays identical regardless of which request "won" the race.
const existing = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, parsed.submission_id))
.limit(1);
if (existing[0]) {
return NextResponse.json({ id: existing[0].id, deduped: true });
}
// Should be unreachable - the conflict means a row exists, so the lookup
// above should always find it. If it doesn't (e.g. simultaneous DELETE),
// surface a 500 explicitly rather than silently 200ing a missing id.
logger.error(
{ submissionId: parsed.submission_id },
'website-inquiry conflict but row not found on lookup',
);
return NextResponse.json({ error: 'Insert failed' }, { status: 500 });
}

View File

@@ -21,7 +21,7 @@ interface ReadyResponse {
} }
/** /**
* Readiness probe verifies that every backing service this process * Readiness probe - verifies that every backing service this process
* needs to serve traffic is reachable. A 503 should drop the pod from the * needs to serve traffic is reachable. A 503 should drop the pod from the
* load balancer until the next probe succeeds; it should not trigger a * load balancer until the next probe succeeds; it should not trigger a
* pod restart (that's what `/api/health` is for). * pod restart (that's what `/api/health` is for).

View File

@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
* exercised by the realapi socket fanout test. * exercised by the realapi socket fanout test.
* *
* Requires super_admin or per-port admin permissions; the engine itself * Requires super_admin or per-port admin permissions; the engine itself
* is idempotent duplicate runs only re-evaluate, never duplicate rows. * is idempotent - duplicate runs only re-evaluate, never duplicate rows.
*/ */
export const POST = withAuth(async (_req, ctx) => { export const POST = withAuth(async (_req, ctx) => {
try { try {

View File

@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
import { checkDocumensoHealth } from '@/lib/services/documenso-client'; import { checkDocumensoHealth } from '@/lib/services/documenso-client';
/** /**
* Admin probe calls Documenso /api/v1/health using the port's effective * Admin probe - calls Documenso /api/v1/health using the port's effective
* config. Used by the "Test connection" button on /admin/documenso. * config. Used by the "Test connection" button on /admin/documenso.
*/ */
export const POST = withAuth( export const POST = withAuth(

View File

@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
.map((p) => { .map((p) => {
const a = clientById.get(p.clientAId); const a = clientById.get(p.clientAId);
const b = clientById.get(p.clientBId); const b = clientById.get(p.clientBId);
if (!a || !b) return null; // FK orphan shouldn't happen, but be defensive if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
// Skip pairs where one side has already been merged or archived. // Skip pairs where one side has already been merged or archived.
if (a.mergedIntoClientId || b.mergedIntoClientId) return null; if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
return { return {

View File

@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
export const GET = withAuth( export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => { withPermission('admin', 'manage_users', async (_req, ctx) => {
try { try {
// crm_user_invites is a global table (no per-port column) invites // crm_user_invites is a global table (no per-port column) - invites
// mint better-auth users that may later be assigned roles in any // mint better-auth users that may later be assigned roles in any
// port. Listing it cross-tenant would let a port-A director // port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags // enumerate pending invitee emails, names, and isSuperAdmin flags

View File

@@ -13,7 +13,7 @@ const schema = z.object({
apiKey: z.string().min(1), apiKey: z.string().min(1),
}); });
// `manage_settings`-gated for parity with the parent OCR settings route // `manage_settings`-gated for parity with the parent OCR settings route -
// triggers outbound AI provider auth requests using a caller-supplied key. // triggers outbound AI provider auth requests using a caller-supplied key.
export const POST = withAuth( export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req) => { withPermission('admin', 'manage_settings', async (req) => {

View File

@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
* POST /api/v1/admin/templates/preview * POST /api/v1/admin/templates/preview
* *
* Generates a preview PDF from a TipTap JSON content block. * Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } the client can render this * Returns { data: { pdfBase64: string } } - the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab. * in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
* *
* Body: * Body:
* content: TipTap JSON document * content: TipTap JSON document
* sampleData?: Record<string, string> variable substitutions * sampleData?: Record<string, string> - variable substitutions
*/ */
export const POST = withAuth( export const POST = withAuth(
withPermission('documents', 'manage', async (req, _ctx) => { withPermission('documents', 'manage', async (req, _ctx) => {
@@ -60,10 +60,7 @@ export const POST = withAuth(
/** /**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc. * Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/ */
function substituteInDoc( function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
if (node.type === 'text' && node.text) { if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) }; return { ...node, text: substituteVariables(node.text, data) };
} }

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { testConnection } from '@/lib/services/umami.service';
/**
* POST /api/v1/admin/umami/test - admin-only Umami connection check.
*
* Returns `{ data: { ok: true, visitors } }` on success or
* `{ data: { ok: false, error } }` on failure. Mirrors the shape used by
* the Documenso health endpoint so the existing test-button UI pattern
* just works.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const result = await testConnection(ctx.portId);
return NextResponse.json({ data: result });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ data: { ok: false, error } });
}
}),
);

View File

@@ -9,6 +9,7 @@ import {
getRevenueBreakdown, getRevenueBreakdown,
type DateRange, type DateRange,
type MetricBase, type MetricBase,
type PresetDateRange,
} from '@/lib/services/analytics.service'; } from '@/lib/services/analytics.service';
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = { const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
@@ -18,17 +19,69 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
lead_source_attribution: getLeadSourceAttribution, lead_source_attribution: getLeadSourceAttribution,
}; };
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
export const GET = withAuth( export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url); const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null; const metric = url.searchParams.get('metric') as MetricBase | null;
const range = (url.searchParams.get('range') ?? '30d') as DateRange; const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (!metric || !(metric in METRICS)) { if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
} }
if (!ALL_RANGES.includes(range)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); let range: DateRange;
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return NextResponse.json(
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return NextResponse.json(
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (fromParam > toParam) {
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
}
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
// (rolls over silently when handed to `new Date`). Re-serialize and
// confirm it matches the input to catch invalid calendar values.
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return NextResponse.json(
{ error: `\`${label}\` is not a valid calendar date` },
{ status: 400 },
);
}
}
// Backstop against the occupancy-timeline N+1 query loop. Each day
// in the range issues its own DB query, so a multi-year custom
// range would saturate the connection pool. 365 days is a generous
// ceiling for analytical queries; if a longer span is needed, the
// service should be restructured to use `generate_series` instead
// of a JS loop.
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
if ((toMs - fromMs) / 86_400_000 > 365) {
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
}
range = { kind: 'custom', from: fromParam, to: toParam };
} else {
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
}
range = rawRange as PresetDateRange;
} }
const data = await METRICS[metric](ctx.portId, range); const data = await METRICS[metric](ctx.portId, range);

View File

@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers'; import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', getHandler)); export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper the required permission depends // PATCH cannot use `withPermission` wrapper - the required permission depends
// on the `action` field in the body. `requirePermission` is called inside the // on the `action` field in the body. `requirePermission` is called inside the
// handler after the body is parsed. // handler after the body is parsed.
export const PATCH = withAuth(patchHandler); export const PATCH = withAuth(patchHandler);

View File

@@ -40,7 +40,7 @@ export const PUT = withAuth(
}), }),
); );
// PATCH /api/v1/berths/[id]/waiting-list reorder a single entry // PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
export const PATCH = withAuth( export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => { withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try { try {

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service'; import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options lightweight list for selects/comboboxes // GET /api/v1/berths/options - lightweight list for selects/comboboxes
export const GET = withAuth( export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => { withPermission('berths', 'view', async (req, ctx) => {
try { try {

View File

@@ -19,7 +19,7 @@ const inviteSchema = z.object({
* *
* Admin creates a portal account for a client and triggers the activation * Admin creates a portal account for a client and triggers the activation
* email. Idempotent in spirit: if a portal user already exists for the * email. Idempotent in spirit: if a portal user already exists for the
* email, returns 409 the admin can resend the activation via * email, returns 409 - the admin can resend the activation via
* ?action=resend. * ?action=resend.
*/ */
export const POST = withAuth( export const POST = withAuth(

View File

@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
const nameResult = rawName ? normalizeName(rawName) : null; const nameResult = rawName ? normalizeName(rawName) : null;
// If the caller didn't give us anything useful to match on, return empty // If the caller didn't give us anything useful to match on, return empty
// short-circuit rather than scan every client for nothing. // - short-circuit rather than scan every client for nothing.
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) { if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
return NextResponse.json({ data: [] }); return NextResponse.json({ data: [] });
} }
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
mediumScore: 50, mediumScore: 50,
}); });
// Only return medium+ low-confidence noise isn't useful at the // Only return medium+ - low-confidence noise isn't useful at the
// create-form layer (background scoring queue picks those up). // create-form layer (background scoring queue picks those up).
const useful = matches.filter((m) => m.confidence !== 'low'); const useful = matches.filter((m) => m.confidence !== 'low');
if (useful.length === 0) { if (useful.length === 0) {

View File

@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service'; import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({ const mergeSchema = z.object({
/** Surviving expense id typically the row's existing `duplicateOf` pointer. */ /** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1), targetId: z.string().min(1),
}); });

View File

@@ -51,7 +51,7 @@ export const POST = withAuth(
}); });
} }
// Per-port budget gate refuse the call before we spend tokens // Per-port budget gate - refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request // when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the // would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking. // success response so the UI can show a banner without blocking.
@@ -99,7 +99,7 @@ export const POST = withAuth(
}); });
} catch (err) { } catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed'); logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup degrade to manual entry rather than 500-ing. // Provider hiccup - degrade to manual entry rather than 500-ing.
return NextResponse.json({ return NextResponse.json({
data: { data: {
parsed: EMPTY, parsed: EMPTY,

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(
try { try {
const body = await parseBody(req, createFolderSchema); const body = await parseBody(req, createFolderSchema);
// Sanitize path no null bytes, no path traversal // Sanitize path - no null bytes, no path traversal
const safePath = body.path const safePath = body.path
.replace(/\x00/g, '') .replace(/\x00/g, '')
.replace(/\.\.\//g, '') .replace(/\.\.\//g, '')

View File

@@ -20,7 +20,7 @@ export const GET = withAuth(
}), }),
); );
// POST /api/v1/interests/[id]/recommendations add manual recommendation // POST /api/v1/interests/[id]/recommendations - add manual recommendation
export const POST = withAuth( export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => { withPermission('interests', 'edit', async (req, ctx, params) => {
try { try {

View File

@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = { const OUTCOME_LABELS: Record<string, string> = {
won: 'Won', won: 'Won',
lost_other_marina: 'Lost went to another marina', lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost unqualified', lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost no response', lost_no_response: 'Lost - no response',
cancelled: 'Cancelled', cancelled: 'Cancelled',
}; };
@@ -187,7 +187,7 @@ function buildAuditDescription(
const outcomeKey = (newValue?.outcome as string | undefined) ?? ''; const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed'; const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
const reason = (newValue?.reason as string | undefined) ?? ''; const reason = (newValue?.reason as string | undefined) ?? '';
return reason ? `Marked as ${label} ${reason}` : `Marked as ${label}`; return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
} }
if (type === 'outcome_cleared') { if (type === 'outcome_cleared') {
@@ -200,9 +200,9 @@ function buildAuditDescription(
const reason = (newValue.reason as string | undefined) ?? ''; const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system'; const auto = userId === 'system';
if (auto) { if (auto) {
return reason ? `${stage} (auto-advanced ${reason})` : `Stage advanced to ${stage}`; return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
} }
return reason ? `Stage changed to ${stage} ${reason}` : `Stage changed to ${stage}`; return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
} }
if (action === 'update' && newValue?.pipelineStage) { if (action === 'update' && newValue?.pipelineStage) {

View File

@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
const results = await search(ctx.portId, q); const results = await search(ctx.portId, q);
// Fire-and-forget do not await // Fire-and-forget - do not await
saveRecentSearch(ctx.userId, ctx.portId, q); saveRecentSearch(ctx.userId, ctx.portId, q);
return NextResponse.json(results); return NextResponse.json(results);

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getStats,
type UmamiMetricType,
} from '@/lib/services/umami.service';
/**
* GET /api/v1/website-analytics?metric=...&range=...
*
* Single endpoint serving every Umami widget on the /website-analytics
* page. Mirrors the shape of /api/v1/analytics so the client side can
* reuse the same hook pattern.
*
* Supported metrics:
* - stats → KPI tiles (pageviews, visitors, visits, etc.)
* - pageviews → time-series for the trend chart
* - active → live "right now" count (range ignored)
* - top-{type} → top pages/referrers/countries/etc.
* where type ∈ url|referrer|country|browser|
* os|device|event
*
* Range param accepts the same presets as /api/v1/analytics, plus
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD`.
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' };
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' };
}
if (fromParam > toParam) {
return { error: '`from` must be on or before `to`' };
}
// Round-trip date check (catches "2026-02-31" type rollovers).
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return { error: `\`${label}\` is not a valid calendar date` };
}
}
return { kind: 'custom', from: fromParam, to: toParam };
}
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return { error: 'Invalid range' };
}
return rawRange as PresetDateRange;
}
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) {
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
}
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
}
const range = rangeOrError as DateRange;
try {
let data: unknown;
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
}
// `data === null` from the service means Umami isn't configured for
// this port - surface that explicitly so the UI can render a
// "configure your credentials" empty state instead of a chart.
if (data === null) {
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
}
return NextResponse.json({ metric, range, data });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message, metric, range }, { status: 502 });
}
}),
);

View File

@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}); });
if (existing) { if (existing) {
logger.info({ signatureHash }, 'Duplicate Documenso webhook skipping'); logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
return NextResponse.json({ ok: true }, { status: 200 }); return NextResponse.json({ ok: true }, { status: 200 });
} }
} catch (err) { } catch (err) {

View File

@@ -93,7 +93,7 @@
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
} }
/* Wave watermark subtle background texture for auth pages */ /* Wave watermark - subtle background texture for auth pages */
.wave-watermark { .wave-watermark {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
135deg, 135deg,
@@ -134,7 +134,7 @@
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback * from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs. * handles desktop browsers resized below lg (1024px), or stripped UAs.
* *
* IMPORTANT: only `display: none` rules are emitted we never set a positive * IMPORTANT: only `display: none` rules are emitted - we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would * display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade). * be overridden by `display: block` (same specificity, later cascade).
*/ */
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
display: none !important; display: none !important;
} }
} }
/*
* Recharts focus-ring suppression.
*
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
* them (the library adds tabindex on chart sectors / paths). The global
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
* which on a chart surface reads as a stray rectangle around the plot
* area. Hover/tooltip already handles chart interactivity, so suppress
* the ring entirely here.
*
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
* it during purge (an earlier copy inside `@layer base` was being
* silently removed at build time, leaving the ring intact).
*/
div.recharts-wrapper:focus,
div.recharts-wrapper:focus-visible,
svg.recharts-surface:focus,
svg.recharts-surface:focus-visible,
div.recharts-responsive-container:focus,
div.recharts-responsive-container:focus-visible,
.recharts-wrapper *:focus,
.recharts-wrapper *:focus-visible {
outline: none !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
--tw-ring-color: transparent !important;
--tw-ring-offset-color: transparent !important;
}

View File

@@ -87,7 +87,7 @@ export function AuditLogList() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
// Filter state debounce text inputs. // Filter state - debounce text inputs.
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all'); const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all'); const [action, setAction] = useState<string>('all');
@@ -215,7 +215,7 @@ export function AuditLogList() {
</span> </span>
); );
} }
return <span className="text-xs text-muted-foreground"></span>; return <span className="text-xs text-muted-foreground">-</span>;
}, },
}, },
{ {
@@ -245,7 +245,7 @@ export function AuditLogList() {
<PageHeader <PageHeader
title="Audit Log" title="Audit Log"
eyebrow="Admin" eyebrow="Admin"
description="Every state change in this port fully searchable." description="Every state change in this port - fully searchable."
variant="gradient" variant="gradient"
/> />

View File

@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldForm({ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
open,
onOpenChange,
field,
onSuccess,
}: CustomFieldFormProps) {
const isEdit = !!field; const isEdit = !!field;
// Form state // Form state
@@ -72,9 +67,7 @@ export function CustomFieldForm({
const [fieldName, setFieldName] = useState(field?.fieldName ?? ''); const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? ''); const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text'); const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
const [selectOptions, setSelectOptions] = useState<string[]>( const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
field?.selectOptions ?? [],
);
const [newOption, setNewOption] = useState(''); const [newOption, setNewOption] = useState('');
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false); const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0); const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
@@ -169,13 +162,11 @@ export function CustomFieldForm({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2"> <form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Entity Type create only */} {/* Entity Type - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-entity-type">Entity Type</Label> <Label htmlFor="cf-entity-type">Entity Type</Label>
{isEdit ? ( {isEdit ? (
@@ -198,7 +189,7 @@ export function CustomFieldForm({
)} )}
</div> </div>
{/* Field Name create only */} {/* Field Name - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-field-name"> <Label htmlFor="cf-field-name">
Field Name Field Name
@@ -232,7 +223,7 @@ export function CustomFieldForm({
/> />
</div> </div>
{/* Field Type create only */} {/* Field Type - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-field-type">Field Type</Label> <Label htmlFor="cf-field-type">Field Type</Label>
{isEdit ? ( {isEdit ? (
@@ -260,7 +251,7 @@ export function CustomFieldForm({
)} )}
</div> </div>
{/* Select Options visible when fieldType = 'select' */} {/* Select Options - visible when fieldType = 'select' */}
{fieldType === 'select' && ( {fieldType === 'select' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Options</Label> <Label>Options</Label>
@@ -302,11 +293,7 @@ export function CustomFieldForm({
{/* Is Required */} {/* Is Required */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="cf-is-required">Required field</Label> <Label htmlFor="cf-is-required">Required field</Label>
<Switch <Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
id="cf-is-required"
checked={isRequired}
onCheckedChange={setIsRequired}
/>
</div> </div>
{/* Sort Order */} {/* Sort Order */}

View File

@@ -11,13 +11,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme'; import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -61,20 +55,13 @@ interface TemplateFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function TemplateForm({ export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
open,
onOpenChange,
template,
onSuccess,
}: TemplateFormProps) {
const isEdit = !!template; const isEdit = !!template;
const [name, setName] = useState(template?.name ?? ''); const [name, setName] = useState(template?.name ?? '');
const [type, setType] = useState(template?.templateType ?? 'other'); const [type, setType] = useState(template?.templateType ?? 'other');
const [contentJson, setContentJson] = useState( const [contentJson, setContentJson] = useState(
template?.content template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
? JSON.stringify(template.content, null, 2)
: EMPTY_DOC,
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -86,7 +73,7 @@ export function TemplateForm({
setJsonError(null); setJsonError(null);
return true; return true;
} catch { } catch {
setJsonError('Invalid JSON check syntax.'); setJsonError('Invalid JSON - check syntax.');
return false; return false;
} }
} }
@@ -115,8 +102,7 @@ export function TemplateForm({
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (err: unknown) { } catch (err: unknown) {
const message = const message = err instanceof Error ? err.message : 'Something went wrong';
err instanceof Error ? err.message : 'Something went wrong';
setError(message); setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -127,9 +113,7 @@ export function TemplateForm({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl"> <SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
{isEdit ? 'Edit Template' : 'New Document Template'}
</SheetTitle>
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6"> <form onSubmit={handleSubmit} className="mt-6 space-y-6">
@@ -145,7 +129,7 @@ export function TemplateForm({
/> />
</div> </div>
{/* Type only on create */} {/* Type - only on create */}
{!isEdit && ( {!isEdit && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="template-type">Document Type</Label> <Label htmlFor="template-type">Document Type</Label>
@@ -166,15 +150,11 @@ export function TemplateForm({
{/* TipTap JSON Content */} {/* TipTap JSON Content */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="template-content"> <Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
Document Content (TipTap JSON)
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Paste or edit TipTap JSON. Use{' '} Paste or edit TipTap JSON. Use{' '}
<code className="rounded bg-muted px-1 text-xs"> <code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
{'{{variable.key}}'} dynamic content.
</code>{' '}
tokens for dynamic content.
</p> </p>
<textarea <textarea
id="template-content" id="template-content"
@@ -187,9 +167,7 @@ export function TemplateForm({
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false} spellCheck={false}
/> />
{jsonError && ( {jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
<p className="text-xs text-destructive">{jsonError}</p>
)}
</div> </div>
{/* Available Variables Reference */} {/* Available Variables Reference */}
@@ -200,19 +178,15 @@ export function TemplateForm({
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2"> <div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
{TEMPLATE_VARIABLES.map((v) => ( {TEMPLATE_VARIABLES.map((v) => (
<div key={v.key} className="text-xs"> <div key={v.key} className="text-xs">
<code className="rounded bg-muted px-1"> <code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
{`{{${v.key}}}`} <span className="text-muted-foreground">- {v.label}</span>
</code>{' '}
<span className="text-muted-foreground"> {v.label}</span>
</div> </div>
))} ))}
</div> </div>
</details> </details>
{error && ( {error && (
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive"> <p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
{error}
</p>
)} )}
<SheetFooter> <SheetFooter>
@@ -225,11 +199,7 @@ export function TemplateForm({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading || !!jsonError}> <Button type="submit" disabled={loading || !!jsonError}>
{loading {loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
? 'Saving…'
: isEdit
? 'Save Changes'
: 'Create Template'}
</Button> </Button>
</SheetFooter> </SheetFooter>
</form> </form>

View File

@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TemplateForm } from './template-form'; import { TemplateForm } from './template-form';
import { TemplateVersionHistory } from './template-version-history'; import { TemplateVersionHistory } from './template-version-history';
@@ -57,9 +52,7 @@ export function TemplateList() {
const fetchTemplates = useCallback(async () => { const fetchTemplates = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await apiFetch<{ data: AdminTemplate[] }>( const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
'/api/v1/admin/templates',
);
setTemplates(res.data); setTemplates(res.data);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -122,9 +115,7 @@ export function TemplateList() {
accessorKey: 'version', accessorKey: 'version',
header: 'Version', header: 'Version',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">v{row.original.version}</span>
v{row.original.version}
</span>
), ),
}, },
{ {
@@ -151,10 +142,7 @@ export function TemplateList() {
header: '', header: '',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<TemplatePreview <TemplatePreview content={row.original.content} templateName={row.original.name} />
content={row.original.content}
templateName={row.original.name}
/>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -177,9 +165,7 @@ export function TemplateList() {
title={row.original.isActive ? 'Deactivate' : 'Activate'} title={row.original.isActive ? 'Deactivate' : 'Activate'}
onClick={() => handleToggleActive(row.original)} onClick={() => handleToggleActive(row.original)}
> >
<span className="text-xs"> <span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
{row.original.isActive ? 'Off' : 'On'}
</span>
</Button> </Button>
<ConfirmationDialog <ConfirmationDialog
trigger={ trigger={
@@ -233,9 +219,7 @@ export function TemplateList() {
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}> <Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto"> <SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
Version History {historyTemplate?.name}
</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-6"> <div className="mt-6">
{historyTemplate && ( {historyTemplate && (

View File

@@ -3,12 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Eye, ExternalLink } from 'lucide-react'; import { Eye, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme'; import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Build sample data from TEMPLATE_VARIABLES examples // Build sample data from TEMPLATE_VARIABLES examples
const sampleData = Object.fromEntries( const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
);
async function handlePreview() { async function handlePreview() {
if (!content) { if (!content) {
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle>Preview {templateName}</DialogTitle> <DialogTitle>Preview - {templateName}</DialogTitle>
{pdfBase64 && ( {pdfBase64 && (
<Button <Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="mr-6"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" /> <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open in new tab Open in new tab
</Button> </Button>
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
)} )}
{error && !loading && ( {error && !loading && (
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive"> <div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
{error}
</div>
)} )}
{pdfBase64 && !loading && ( {pdfBase64 && !loading && (

View File

@@ -117,7 +117,7 @@ export function InvitationsManager() {
{invites.map((i) => ( {invites.map((i) => (
<tr key={i.id} className="border-t"> <tr key={i.id} className="border-t">
<td className="px-3 py-2 font-medium">{i.email}</td> <td className="px-3 py-2 font-medium">{i.email}</td>
<td className="px-3 py-2 text-muted-foreground">{i.name ?? ''}</td> <td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.isSuperAdmin ? 'Super admin' : 'Standard user'} {i.isSuperAdmin ? 'Super admin' : 'Standard user'}
</td> </td>
@@ -163,7 +163,7 @@ export function InvitationsManager() {
)} )}
</div> </div>
) : ( ) : (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground">-</span>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
Enable AI receipt parsing for this port Enable AI receipt parsing for this port
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Off by default. Receipts are read on-device using Tesseract.js accurate enough for Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
most receipts and incurs no AI cost. Turning this on lets the configured provider most receipts and incurs no AI cost. Turning this on lets the configured provider
re-parse receipts server-side for higher accuracy on hard-to-read images. re-parse receipts server-side for higher accuracy on hard-to-read images.
</p> </p>
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
id={`apiKey-${scope}`} id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
autoComplete="off" autoComplete="off"
placeholder={hasKey ? '•••••• (saved leave blank to keep)' : 'sk-…'} placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
value={apiKey} value={apiKey}
onChange={(e) => { onChange={(e) => {
setApiKey(e.target.value); setApiKey(e.target.value);

View File

@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
}; };
function formatDate(ts: number | undefined): string { function formatDate(ts: number | undefined): string {
if (!ts) return ''; if (!ts) return '-';
return new Date(ts).toLocaleString(); return new Date(ts).toLocaleString();
} }
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
} }
function truncateReason(reason: string | undefined): string { function truncateReason(reason: string | undefined): string {
if (!reason) return ''; if (!reason) return '-';
return reason.length > 80 ? `${reason.slice(0, 80)}` : reason; return reason.length > 80 ? `${reason.slice(0, 80)}` : reason;
} }
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span> <span>
{total} total jobs page {page} of {totalPages} {total} total jobs - page {page} of {totalPages}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button

View File

@@ -95,7 +95,7 @@ export function RoleList() {
accessorKey: 'description', accessorKey: 'description',
header: 'Description', header: 'Description',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-muted-foreground text-sm">{row.original.description ?? ''}</span> <span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
), ),
}, },
{ {

View File

@@ -363,7 +363,7 @@ export function SettingsManager() {
); );
void saveSetting(setting.key, parsed); void saveSetting(setting.key, parsed);
} catch { } catch {
// invalid JSON do nothing // invalid JSON - do nothing
} }
}} }}
> >

View File

@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{/* Email subtitle only when display name is shown as title */} {/* Email subtitle - only when display name is shown as title */}
{user.displayName && user.displayName !== user.email ? ( {user.displayName && user.displayName !== user.email ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground"> <p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden /> <Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />

View File

@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
useEffect(() => { useEffect(() => {
void load(page); void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhookId, page]); }, [webhookId, page]);
if (loading && deliveries.length === 0) { if (loading && deliveries.length === 0) {
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
<TableRow key={d.id}> <TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.eventType}</TableCell> <TableCell className="font-mono text-xs">{d.eventType}</TableCell>
<TableCell> <TableCell>
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}> <Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-sm">
{d.responseStatus ?? '—'}
</TableCell> </TableCell>
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
<TableCell className="text-sm">{d.attempt}</TableCell> <TableCell className="text-sm">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{d.deliveredAt {d.deliveredAt

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface TestResponse {
ok: boolean;
visitors?: number;
error?: string;
}
/**
* Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/
* active` to verify auth + websiteId in one request. On success, shows the
* live visitor count as proof we got real data back.
*/
export function UmamiTestButton() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<TestResponse | null>(null);
async function runTest() {
setPending(true);
setResult(null);
try {
const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', {
method: 'POST',
});
setResult(res.data);
if (res.data.ok) {
toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`);
} else {
toast.error(res.data.error ?? 'Umami test failed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Test failed';
setResult({ ok: false, error: message });
toast.error(message);
} finally {
setPending(false);
}
}
return (
<div className="flex items-center gap-3">
{result &&
(result.ok ? (
<span className="flex items-center text-xs text-green-600">
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Connected ({result.visitors ?? 0} active)
</span>
) : (
<span className="flex items-center text-xs text-destructive">
<XCircle className="mr-1 h-3.5 w-3.5" />
{result.error ?? 'Failed'}
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={runTest} disabled={pending}>
{pending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Test connection
</Button>
</div>
);
}

View File

@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
export function AlertBell() { export function AlertBell() {
const portSlug = useUIStore((s) => s.currentPortSlug); const portSlug = useUIStore((s) => s.currentPortSlug);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Count is cheap (one aggregate query) fire on every page so the badge stays live. // Count is cheap (one aggregate query) - fire on every page so the badge stays live.
// List is heavier only fetch when the popover is actually open. // List is heavier - only fetch when the popover is actually open.
const { data: count } = useAlertCount(); const { data: count } = useAlertCount();
const { data: list, isLoading } = useAlertList('open', open); const { data: list, isLoading } = useAlertList('open', open);
useAlertRealtime(); useAlertRealtime();

View File

@@ -22,11 +22,10 @@ export function AlertRail() {
<section <section
data-testid="alert-rail" data-testid="alert-rail"
aria-label="Active alerts" aria-label="Active alerts"
// `h-full` is intentional only at xl: where the parent dashboard grid // Natural height - the parent aside no longer forces 100% of the
// gives this rail a sibling column whose height it should match. On // dashboard grid row, so the rail can sit compactly under Reminders
// mobile (single-column stack) there's no fixed-height context, so // without bleeding down into the Recent Activity panel below.
// forcing 100% height makes the section overflow / look stretched. className="flex flex-col gap-3"
className="flex flex-col gap-3 xl:h-full"
> >
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2> <h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
@@ -57,7 +56,7 @@ export function AlertRail() {
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)} href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent" className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
> >
+{overflow} more view all +{overflow} more - view all
</Link> </Link>
) : null} ) : null}
</div> </div>

View File

@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</Button> </Button>
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'mooringNumber', accessorKey: 'mooringNumber',
header: 'Mooring #', header: 'Mooring #',
cell: ({ row }) => ( cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
<span className="font-medium">{row.original.mooringNumber}</span>
),
}, },
{ {
accessorKey: 'area', accessorKey: 'area',
header: 'Area', header: 'Area',
cell: ({ row }) => row.original.area ?? '', cell: ({ row }) => row.original.area ?? '-',
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const { lengthM, widthM } = row.original; const { lengthM, widthM } = row.original;
if (!lengthM && !widthM) return ''; if (!lengthM && !widthM) return '-';
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`; return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
}, },
}, },
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Price', header: 'Price',
cell: ({ row }) => { cell: ({ row }) => {
const { price, priceCurrency } = row.original; const { price, priceCurrency } = row.original;
if (!price) return ''; if (!price) return '-';
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: priceCurrency || 'USD', currency: priceCurrency || 'USD',
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'tenureType', accessorKey: 'tenureType',
header: 'Tenure', header: 'Tenure',
cell: ({ row }) => cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
}, },
{ {
id: 'tags', id: 'tags',

View File

@@ -93,7 +93,7 @@ function SelectOrEmpty({
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NONE}></SelectItem> <SelectItem value={NONE}>-</SelectItem>
{options.map((opt) => ( {options.map((opt) => (
<SelectItem key={opt} value={opt}> <SelectItem key={opt} value={opt}>
{opt} {opt}

View File

@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
href={`/${portSlug}/interests/${i.id}` as never} href={`/${portSlug}/interests/${i.id}` as never}
className="hover:text-brand" className="hover:text-brand"
> >
{i.clientName ?? ''} {i.clientName ?? '-'}
</Link> </Link>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : ''} {i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : ''} {i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
</td> </td>
<td className="px-3 py-2 text-xs text-muted-foreground"> <td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()} {new Date(i.createdAt).toLocaleDateString()}

View File

@@ -36,7 +36,7 @@ export function BerthList() {
title="Berths" title="Berths"
description="View and manage berth allocations" description="View and manage berth allocations"
variant="gradient" variant="gradient"
// No "New" button berths are import-only // No "New" button - berths are import-only
/> />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">

View File

@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sales pulse top-of-page so reps doing berth-level triage can see {/* Sales pulse - top-of-page so reps doing berth-level triage can see
who's interested + how warm without clicking into the Interests tab. */} who's interested + how warm without clicking into the Interests tab. */}
<BerthInterestPulse berthId={berth.id} /> <BerthInterestPulse berthId={berth.id} />

View File

@@ -70,7 +70,7 @@ export function getClientColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const primary = row.original.contacts?.find((c) => c.isPrimary); const primary = row.original.contacts?.find((c) => c.isPrimary);
if (!primary) return <span className="text-muted-foreground"></span>; if (!primary) return <span className="text-muted-foreground">-</span>;
return ( return (
<span className="text-sm"> <span className="text-sm">
<span className="text-muted-foreground capitalize">{primary.channel}: </span> <span className="text-muted-foreground capitalize">{primary.channel}: </span>
@@ -86,7 +86,7 @@ export function getClientColumns({
cell: ({ getValue }) => { cell: ({ getValue }) => {
const iso = getValue() as string | null; const iso = getValue() as string | null;
return ( return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : ''}</span> <span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
); );
}, },
}, },
@@ -96,7 +96,7 @@ export function getClientColumns({
header: 'Source', header: 'Source',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const source = getValue() as string | null; const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>; if (!source) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="capitalize text-xs"> <Badge variant="outline" className="capitalize text-xs">
{SOURCE_LABELS[source] ?? source} {SOURCE_LABELS[source] ?? source}
@@ -111,7 +111,7 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.yachtCount ?? 0; const c = row.original.yachtCount ?? 0;
return c === 0 ? ( return c === 0 ? (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
) : ( ) : (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{c} {c}
@@ -126,7 +126,7 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.companyCount ?? 0; const c = row.original.companyCount ?? 0;
return c === 0 ? ( return c === 0 ? (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
) : ( ) : (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{c} {c}
@@ -140,7 +140,7 @@ export function getClientColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const clientTags = row.original.tags ?? []; const clientTags = row.original.tags ?? [];
if (clientTags.length === 0) return <span className="text-muted-foreground"></span>; if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{clientTags.slice(0, 3).map((tag) => ( {clientTags.slice(0, 3).map((tag) => (

View File

@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
function formatSince(startDate: string | Date): string { function formatSince(startDate: string | Date): string {
const d = typeof startDate === 'string' ? new Date(startDate) : startDate; const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
if (Number.isNaN(d.getTime())) return ''; if (Number.isNaN(d.getTime())) return '-';
return format(d, 'MMM d, yyyy'); return format(d, 'MMM d, yyyy');
} }
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
Primary Primary
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">

View File

@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)} )}
</div> </div>
{/* Top-right: archive/restore as a small icon button destructive {/* Top-right: archive/restore as a small icon button - destructive
action sits out of the primary action flow. */} action sits out of the primary action flow. */}
<button <button
type="button" type="button"

View File

@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6"> <form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
{/* Dedup suggestion only on the create path. Watches the {/* Dedup suggestion - only on the create path. Watches the
live form values for email / phone / name and surfaces live form values for email / phone / name and surfaces
an existing client when one matches. The user can an existing client when one matches. The user can
attach the new interest to that client instead of attach the new interest to that client instead of

View File

@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
}) { }) {
// Pin the most recently selected interest so the drawer stays populated // Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms // during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here the guard // after `open=false`). Conditional setState is safe here - the guard
// ensures it only fires when the prop actually changes to a new row. // ensures it only fires when the prop actually changes to a new row.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest); const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest); if (interest && interest !== pinned) setPinned(interest);
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
</DrawerHeader> </DrawerHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4"> <div className="space-y-5 overflow-y-auto px-4 pb-4">
{/* Pipeline-stepper segmented bar the same primitive used on the {/* Pipeline-stepper segmented bar - the same primitive used on the
row card, so the at-a-glance progress hint is consistent row card, so the at-a-glance progress hint is consistent
across surfaces. */} across surfaces. */}
{stage ? ( {stage ? (
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
</div> </div>
) : null} ) : null}
{/* Milestones three sections matching the full interest detail {/* Milestones - three sections matching the full interest detail
page (EOI / Deposit / Contract). Done-state is derived from page (EOI / Deposit / Contract). Done-state is derived from
the pipeline stage so seed data without per-step dates still the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step renders correctly. The full milestone columns + per-step
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
</div> </div>
</section> </section>
{/* Compact key/value pairs lead category, source, last contact, {/* Compact key/value pairs - lead category, source, last contact,
activity. Each row collapses cleanly when its value is activity. Each row collapses cleanly when its value is
missing so the drawer scales from sparse seed data to full missing so the drawer scales from sparse seed data to full
records without empty placeholders. */} records without empty placeholders. */}

View File

@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
interface PipelineSummaryProps { interface PipelineSummaryProps {
clientId: string; clientId: string;
/** /**
* `hero` single-line pulse for the detail header (highest active stage only). * `hero` - single-line pulse for the detail header (highest active stage only).
* `panel` compact list of every active interest, for the Overview tab. * `panel` - compact list of every active interest, for the Overview tab.
*/ */
variant?: 'hero' | 'panel'; variant?: 'hero' | 'panel';
} }

View File

@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : ''} {y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
</TableCell> </TableCell>
<TableCell>{y.hullNumber ?? ''}</TableCell> <TableCell>{y.hullNumber ?? '-'}</TableCell>
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell> <TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -225,10 +225,10 @@ function ContactRow({
{/* Bottom / right: tag + actions. {/* Bottom / right: tag + actions.
Two layers of hiding compose here: Two layers of hiding compose here:
(a) phoneEditing when the phone editor is open, hide the entire (a) phoneEditing - when the phone editor is open, hide the entire
action cluster (tag + star + trash) so the user can focus on action cluster (tag + star + trash) so the user can focus on
the form without chips fighting for space. the form without chips fighting for space.
(b) contact.value when the value is empty (stale import row, (b) contact.value - when the value is empty (stale import row,
aborted edit), hide just the tag + Make-primary star; aborted edit), hide just the tag + Make-primary star;
neither makes sense without a value. The trash icon stays neither makes sense without a value. The trash icon stays
so the user can clean up the empty entry. so the user can clean up the empty entry.

View File

@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' }); setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
// Clear the dismissed flag when inputs change the user typed // Clear the dismissed flag when inputs change - the user typed
// something new, so the prior dismissal no longer applies. // something new, so the prior dismissal no longer applies.
setDismissed(false); setDismissed(false);
}, 300); }, 300);
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`); return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
}, },
enabled: hasSomething && !dismissed, enabled: hasSomething && !dismissed,
// Same query is fine to cache for a minute moves are slow at this layer. // Same query is fine to cache for a minute - moves are slow at this layer.
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
<p className="text-sm font-semibold leading-tight"> <p className="text-sm font-semibold leading-tight">
{isHigh {isHigh
? 'This looks like an existing client' ? 'This looks like an existing client'
: 'Possible match check before creating'} : 'Possible match - check before creating'}
</p> </p>
<div className="mt-2 rounded-md border bg-background/80 p-2.5"> <div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
}, },
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Export queued refresh in ~30 seconds'); toast.success('Export queued - refresh in ~30 seconds');
qc.invalidateQueries({ queryKey }); qc.invalidateQueries({ queryKey });
setEmailOverride(''); setEmailOverride('');
}, },
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
Email the bundle when ready Email the bundle when ready
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sends a 7-day signed download link to the client&apos;s primary email or to the Sends a 7-day signed download link to the client&apos;s primary email - or to the
override below. override below.
</p> </p>
{emailToClient ? ( {emailToClient ? (

View File

@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
let msg = err instanceof Error ? err.message : 'Failed to add membership'; let msg = err instanceof Error ? err.message : 'Failed to add membership';
// Detect 409 service returns a "membership already exists" message // Detect 409 - service returns a "membership already exists" message
if (/already exists/i.test(msg)) { if (/already exists/i.test(msg)) {
msg = 'This membership already exists (same client + role + start date).'; msg = 'This membership already exists (same client + role + start date).';
} }

View File

@@ -76,7 +76,7 @@ export function getCompanyColumns({
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue() as string | null; const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>; if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>; return <span className="text-sm">{value}</span>;
}, },
}, },
@@ -87,7 +87,7 @@ export function getCompanyColumns({
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue() as string | null; const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>; if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>; return <span className="text-sm">{value}</span>;
}, },
}, },
@@ -98,7 +98,7 @@ export function getCompanyColumns({
size: 88, size: 88,
cell: ({ row }) => { cell: ({ row }) => {
const n = row.original.memberCount ?? 0; const n = row.original.memberCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>; if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>; return <Badge variant="secondary">{n}</Badge>;
}, },
}, },
@@ -109,7 +109,7 @@ export function getCompanyColumns({
size: 88, size: 88,
cell: ({ row }) => { cell: ({ row }) => {
const n = row.original.yachtCount ?? 0; const n = row.original.yachtCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>; if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>; return <Badge variant="secondary">{n}</Badge>;
}, },
}, },

View File

@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => { mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) { if (isEdit) {
// updateCompanySchema omits tagIds strip them from PATCH body. // updateCompanySchema omits tagIds - strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data; const { tagIds: _tIds, ...rest } = data;
void _tIds; void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, { await apiFetch(`/api/v1/companies/${company!.id}`, {
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
value={watch('incorporationCountryIso')} value={watch('incorporationCountryIso')}
onChange={(iso) => { onChange={(iso) => {
setValue('incorporationCountryIso', iso ?? undefined); setValue('incorporationCountryIso', iso ?? undefined);
// Wipe subdivision when country flips codes are country-scoped. // Wipe subdivision when country flips - codes are country-scoped.
setValue('incorporationSubdivisionIso', undefined); setValue('incorporationSubdivisionIso', undefined);
}} }}
data-testid="company-incorp-country" data-testid="company-incorp-country"

View File

@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
}; };
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) return ''; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(); return date.toLocaleDateString();
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
</TableCell> </TableCell>
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell> <TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate"> <TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
{m.roleDetail ?? ''} {m.roleDetail ?? '-'}
</TableCell> </TableCell>
<TableCell>{formatDate(m.startDate)}</TableCell> <TableCell>{formatDate(m.startDate)}</TableCell>
<TableCell> <TableCell>
{m.endDate ? ( {m.endDate ? (
formatDate(m.endDate) formatDate(m.endDate)
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
Primary Primary
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
function formatDimensions(y: OwnedYachtRow): string | null { function formatDimensions(y: OwnedYachtRow): string | null {
if (y.lengthFt || y.widthFt) { if (y.lengthFt || y.widthFt) {
const length = y.lengthFt ?? ''; const length = y.lengthFt ?? '-';
const width = y.widthFt ?? ''; const width = y.widthFt ?? '-';
return `${length} × ${width} ft`; return `${length} × ${width} ft`;
} }
if (y.lengthM || y.widthM) { if (y.lengthM || y.widthM) {
const length = y.lengthM ?? ''; const length = y.lengthM ?? '-';
const width = y.widthM ?? ''; const width = y.widthM ?? '-';
return `${length} × ${width} m`; return `${length} × ${width} m`;
} }
return null; return null;
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
{dims ? ( {dims ? (
<span className="text-sm">{dims}</span> <span className="text-sm">{dims}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{y.hullNumber ? ( {y.hullNumber ? (
<span className="text-sm">{y.hullNumber}</span> <span className="text-sm">{y.hullNumber}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
<InlineCountryField <InlineCountryField
value={company.incorporationCountryIso} value={company.incorporationCountryIso}
onSave={async (iso) => { onSave={async (iso) => {
// Wipe subdivision when country flips codes are country-scoped. // Wipe subdivision when country flips - codes are country-scoped.
await mutation.mutateAsync({ await mutation.mutateAsync({
incorporationCountryIso: iso, incorporationCountryIso: iso,
incorporationSubdivisionIso: null, incorporationSubdivisionIso: null,
@@ -175,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
variant="textarea" variant="textarea"
value={company.notes} value={company.notes}
onSave={save('notes')} onSave={save('notes')}
emptyText="No notes click to add" emptyText="No notes - click to add"
/> />
</div> </div>

View File

@@ -58,7 +58,7 @@ function ActivityFeedInner() {
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No recent activity yet your team&apos;s actions (interests created, stages changed, No recent activity yet - your team&apos;s actions (interests created, stages changed,
invoices sent) will appear here. invoices sent) will appear here.
</p> </p>
) : ( ) : (

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { usePortContext } from '@/providers/port-provider';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { KpiCardsWithBoundary } from './kpi-cards'; import { KpiCardsWithBoundary } from './kpi-cards';
import { ActivityFeed } from './activity-feed'; import { ActivityFeed } from './activity-feed';
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart'; import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart'; import { LeadSourceChart } from './lead-source-chart';
import { MyRemindersRail } from './my-reminders-rail'; import { MyRemindersRail } from './my-reminders-rail';
import { WebsiteGlanceTile } from './website-glance-tile';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail'; import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/services/analytics.service'; import { isCustomRange, type DateRange } from '@/lib/analytics/range';
const RANGE_LABELS: Record<DateRange, string> = { const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
today: 'Today', today: 'Today',
'7d': 'Last 7 days', '7d': 'Last 7 days',
'30d': 'Last 30 days', '30d': 'Last 30 days',
'90d': 'Last 90 days', '90d': 'Last 90 days',
}; };
function rangeLabel(range: DateRange): string {
if (isCustomRange(range)) {
const fmt: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
};
const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
return `${from} ${to}`;
}
return PRESET_LABELS[range];
}
export function DashboardShell() { export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d'); const [range, setRange] = useState<DateRange>('30d');
const { currentPort } = usePortContext();
const portName = currentPort?.name ?? 'this port';
// Use a partial query-key prefix (no range segment) for invalidations.
// Reading: "any cached analytics result, regardless of range, please
// refetch on this event." This avoids any chance that a custom-range
// object literal hashes differently than the one stored in the cache,
// and keeps the invalidation surface broad enough to refresh whichever
// range the user is currently looking at.
useRealtimeInvalidation({ useRealtimeInvalidation({
'interest:stageChanged': [ 'interest:stageChanged': [
['analytics', 'pipeline_funnel', range], ['analytics', 'pipeline_funnel'],
['analytics', 'lead_source_attribution', range], ['analytics', 'lead_source_attribution'],
['dashboard', 'kpis'], ['dashboard', 'kpis'],
], ],
'client:created': [['dashboard', 'kpis']], 'client:created': [['dashboard', 'kpis']],
'berth:statusChanged': [ 'berth:statusChanged': [
['analytics', 'occupancy_timeline', range], ['analytics', 'occupancy_timeline'],
['dashboard', 'kpis'], ['dashboard', 'kpis'],
], ],
}); });
@@ -44,8 +69,8 @@ export function DashboardShell() {
<PageHeader <PageHeader
title="Dashboard" title="Dashboard"
eyebrow="Overview" eyebrow="Overview"
description="Live snapshot of your marina activity" description={`Live snapshot of ${portName} activity`}
kpiLine={<span>{RANGE_LABELS[range]}</span>} kpiLine={<span>{rangeLabel(range)}</span>}
variant="gradient" variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />} actions={<DateRangePicker value={range} onChange={setRange} />}
/> />
@@ -54,7 +79,12 @@ export function DashboardShell() {
<KpiCardsWithBoundary /> <KpiCardsWithBoundary />
</div> </div>
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]"> {/* `items-start` is critical: without it, the right-column aside is
stretched to match the chart column's row height, which forces
MyRemindersRail (or any other child with `h-full`) to push later
children out of the aside's box and into the rows below where
ActivityFeed renders. */}
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2"> <div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary> <WidgetErrorBoundary>
<PipelineFunnelChart range={range} /> <PipelineFunnelChart range={range} />
@@ -70,6 +100,11 @@ export function DashboardShell() {
</WidgetErrorBoundary> </WidgetErrorBoundary>
</div> </div>
<aside className="min-w-0 space-y-4"> <aside className="min-w-0 space-y-4">
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
isn't configured for this port. */}
<WidgetErrorBoundary>
<WebsiteGlanceTile />
</WidgetErrorBoundary>
<WidgetErrorBoundary> <WidgetErrorBoundary>
<MyRemindersRail /> <MyRemindersRail />
</WidgetErrorBoundary> </WidgetErrorBoundary>

View File

@@ -1,8 +1,12 @@
'use client'; 'use client';
import { useState } from 'react';
import { Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { DateRange } from '@/lib/services/analytics.service'; import { isCustomRange, type DateRange } from '@/lib/analytics/range';
interface DateRangePickerProps { interface DateRangePickerProps {
value: DateRange; value: DateRange;
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
className?: string; className?: string;
} }
const OPTIONS: Array<{ value: DateRange; label: string }> = [ const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
{ value: 'today', label: 'Today' }, { value: 'today', label: 'Today' },
{ value: '7d', label: '7d' }, { value: '7d', label: '7d' },
{ value: '30d', label: '30d' }, { value: '30d', label: '30d' },
{ value: '90d', label: '90d' }, { value: '90d', label: '90d' },
]; ];
/**
* Format a custom range as a compact button label, e.g. "Apr 14 May 4".
* Same year omits the year on both sides; different years includes both.
*/
function formatCustom(range: { from: string; to: string }): string {
const from = new Date(`${range.from}T00:00:00.000Z`);
const to = new Date(`${range.to}T00:00:00.000Z`);
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
const fmt: Intl.DateTimeFormatOptions = sameYear
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
return `${from.toLocaleDateString('en-US', fmt)} ${to.toLocaleDateString('en-US', fmt)}`;
}
/**
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
* "to" picker so users can't accidentally pick a future date by leaving the
* field empty.
*/
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) { export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const isCustom = isCustomRange(value);
// Local state for the popover form. Seeded from the current value if it's
// already custom, otherwise defaults to a 14-day window ending today.
const [draftFrom, setDraftFrom] = useState<string>(() => {
if (isCustom) return value.from;
const d = new Date();
d.setUTCDate(d.getUTCDate() - 14);
return d.toISOString().slice(0, 10);
});
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
const today = todayIso();
const draftValid =
draftFrom !== '' &&
draftTo !== '' &&
draftFrom <= draftTo &&
draftFrom <= today &&
draftTo <= today;
function applyCustom() {
if (!draftValid) return;
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
setOpen(false);
}
return ( return (
<div <div
role="tablist" role="tablist"
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
className, className,
)} )}
> >
{OPTIONS.map((opt) => { {PRESETS.map((opt) => {
const active = opt.value === value; const active = !isCustom && opt.value === value;
return ( return (
<Button <Button
key={opt.value} key={opt.value}
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
</Button> </Button>
); );
})} })}
{/* Custom range - popover with two date inputs and an Apply button */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
role="tab"
aria-selected={isCustom}
variant="ghost"
size="sm"
className={cn(
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
isCustom
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
data-testid="range-custom"
>
<Calendar className="h-3 w-3" aria-hidden />
{isCustom ? formatCustom(value) : 'Custom'}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[260px] p-3">
<div className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Custom range
</div>
<label className="block text-xs">
<span className="block text-muted-foreground mb-1">From</span>
<input
type="date"
value={draftFrom}
/* `max` capped at min(draftTo, today). Without the today
cap, users could pick a future From, end up with an
empty result, and not understand why. */
max={draftTo && draftTo < today ? draftTo : today}
onChange={(e) => setDraftFrom(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<label className="block text-xs">
<span className="block text-muted-foreground mb-1">To</span>
<input
type="date"
value={draftTo}
min={draftFrom || undefined}
max={today}
onChange={(e) => setDraftTo(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
/>
</label>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
Apply
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useUIStore } from '@/stores/ui-store';
import { KPITile } from '@/components/ui/kpi-tile'; import { KPITile } from '@/components/ui/kpi-tile';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
} }
export function KpiCards() { export function KpiCards() {
// Keying on currentPortId ensures React Query treats a port-resolved fetch
// as a different query than the one that fires on first paint when the
// store hasn't yet hydrated. Without this, an early null-port fetch could
// cache an error and display "-" indefinitely until the staleTime expires.
const portId = useUIStore((s) => s.currentPortId);
const { data, isLoading, isError } = useQuery<KpiData>({ const { data, isLoading, isError } = useQuery<KpiData>({
queryKey: ['dashboard', 'kpis'], queryKey: ['dashboard', 'kpis', portId],
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'), queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
staleTime: 60_000, staleTime: 60_000,
retry: 2, retry: 2,
// Avoid running until we have a port id - gates against the early
// unauth/no-port window where the API would return zeroes/errors.
enabled: !!portId,
}); });
if (isLoading) { if (isLoading) {
@@ -62,22 +71,22 @@ export function KpiCards() {
}> = [ }> = [
{ {
label: 'Total Clients', label: 'Total Clients',
value: isError ? '' : String(data?.totalClients ?? 0), value: isError ? '-' : String(data?.totalClients ?? 0),
accent: 'brand', accent: 'brand',
}, },
{ {
label: 'Active Interests', label: 'Active Interests',
value: isError ? '' : String(data?.activeInterests ?? 0), value: isError ? '-' : String(data?.activeInterests ?? 0),
accent: 'teal', accent: 'teal',
}, },
{ {
label: 'Pipeline Value', label: 'Pipeline Value',
value: isError ? '' : formatCurrency(data?.pipelineValueUsd ?? 0), value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
accent: 'success', accent: 'success',
}, },
{ {
label: 'Occupancy Rate', label: 'Occupancy Rate',
value: isError ? '' : formatPercent(data?.occupancyRate ?? 0), value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
accent: 'purple', accent: 'purple',
}, },
]; ];

View File

@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
) : !slices.length ? ( ) : !slices.length ? (
<EmptyState <EmptyState
title="No interests in range" title="No interests in range"
description="Lights up once new interests are created tracks where each came from (website, referral, broker)." description="Lights up once new interests are created - tracks where each came from (website, referral, broker)."
/> />
) : ( ) : (
// Percentage radii + center-anchored chart so the pie scales with // Percentage radii + center-anchored chart so the pie scales with

View File

@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
/** /**
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned * Compact reminders rail for the dashboard sidebar. Lists reminders assigned
* to the current user (overdue first, then upcoming). Each item links to its * to the current user (overdue first, then upcoming). Each item links to its
* subject interest preferred, then client, then the generic entity ref. * subject - interest preferred, then client, then the generic entity ref.
* *
* Limited to 6 items; "View all" routes to /reminders. * Limited to 6 items; "View all" routes to /reminders.
*/ */
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
return `/${portSlug}/reminders`; return `/${portSlug}/reminders`;
} }
// `h-full` only at xl: where the dashboard grid pairs this rail with // Natural height - the parent dashboard grid uses `items-start` so the
// a sibling chart column. On mobile (stacked) it produced a weirdly // aside column no longer forces this rail to fill the chart column's
// tall empty card. // height. Stretching here pushed AlertRail out of the aside's box and
// into the territory below where ActivityFeed renders, producing a
// visible overlap on tall viewports.
return ( return (
<Card className="xl:h-full"> <Card>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3"> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<CardTitle className="flex items-center gap-1.5 text-base"> <CardTitle className="flex items-center gap-1.5 text-base">
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
</div> </div>
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<p className="py-3 text-center text-sm text-muted-foreground"> <p className="py-3 text-center text-sm text-muted-foreground">
All caught up no reminders. All caught up - no reminders.
</p> </p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">

View File

@@ -3,8 +3,8 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import type { import type {
DateRange,
LeadSourceAttributionData, LeadSourceAttributionData,
MetricBase, MetricBase,
OccupancyTimelineData, OccupancyTimelineData,
@@ -18,12 +18,27 @@ interface MetricResponse<T> {
data: T; data: T;
} }
/**
* Serialize a DateRange (preset or custom) into the URL query params the
* /api/v1/analytics route expects: `range=30d` for presets, or
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD` for custom.
*/
function rangeToQuery(range: DateRange): string {
if (isCustomRange(range)) {
return `range=custom&from=${range.from}&to=${range.to}`;
}
return `range=${range}`;
}
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) { export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
return useQuery<T>({ return useQuery<T>({
// Stringify custom ranges into the cache key so React Query treats
// each {from,to} pair as its own query - otherwise switching dates
// would never refetch.
queryKey: ['analytics', metric, range], queryKey: ['analytics', metric, range],
queryFn: async () => { queryFn: async () => {
const res = await apiFetch<MetricResponse<T>>( const res = await apiFetch<MetricResponse<T>>(
`/api/v1/analytics?metric=${metric}&range=${range}`, `/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
); );
return res.data; return res.data;
}, },

View File

@@ -0,0 +1,79 @@
'use client';
/**
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews today + active visitors right now + a deep-link to the full
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
* configured for this port - so the dashboard doesn't get cluttered with
* a "configure Umami" prompt that the user already saw on the dedicated
* page.
*/
import Link from 'next/link';
import { Globe, ArrowRight } from 'lucide-react';
import { useUIStore } from '@/stores/ui-store';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
useUmamiActive,
useUmamiStats,
} from '@/components/website-analytics/use-website-analytics';
export function WebsiteGlanceTile() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const stats = useUmamiStats('today');
const active = useUmamiActive('today');
// Hide the tile entirely if Umami isn't configured - this dashboard is
// for sales, not for prompting the operator into integration setup.
if (
stats.data?.error === 'umami_not_configured' ||
active.data?.error === 'umami_not_configured'
) {
return null;
}
const today = stats.data?.data?.pageviews?.value ?? 0;
const activeNow = active.data?.data?.visitors ?? 0;
const loading = stats.isLoading || active.isLoading;
return (
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/website-analytics` as any) : ('/' as any)}
className="block group"
>
<Card className="relative overflow-hidden p-3 sm:p-5 transition-shadow hover:shadow-md">
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
<Globe className="h-3 w-3" aria-hidden />
Website today
</div>
{loading ? (
<Skeleton className="mt-2 h-7 w-20" />
) : (
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
{today.toLocaleString()}
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
</div>
)}
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{activeNow} active right now
</div>
</div>
<ArrowRight
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
</Card>
</Link>
);
}

View File

@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> }; data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' }); }>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
toast.info( toast.info(
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} opens in PR8 wizard`, `Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
); );
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to prepare email'); toast.error(err instanceof Error ? err.message : 'Failed to prepare email');

View File

@@ -26,7 +26,7 @@ interface DocumentListProps {
interestId?: string; interestId?: string;
clientId?: string; clientId?: string;
/** Override the default empty state ("No documents yet.") with a contextual /** Override the default empty state ("No documents yet.") with a contextual
* CTA e.g. on the interest Documents tab we render a Generate EOI prompt. */ * CTA - e.g. on the interest Documents tab we render a Generate EOI prompt. */
emptyState?: React.ReactNode; emptyState?: React.ReactNode;
} }
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
}; };
const getSignerProgress = (doc: DocumentRow) => { const getSignerProgress = (doc: DocumentRow) => {
if (!doc.signers) return ''; if (!doc.signers) return '-';
const signed = doc.signers.filter((s) => s.status === 'signed').length; const signed = doc.signers.filter((s) => s.status === 'signed').length;
return `${signed}/${doc.signers.length} signed`; return `${signed}/${doc.signers.length} signed`;
}; };

View File

@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')} {isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
</StatusPill> </StatusPill>
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : ''} {totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')} {new Date(doc.createdAt).toLocaleDateString('en-GB')}

View File

@@ -22,14 +22,14 @@ import {
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
/** Required for the EOI's top paragraph (Section 2) without these the /** Required for the EOI's top paragraph (Section 2) - without these the
* document is unsignable, so generation is blocked. Yacht and berth fields * document is unsignable, so generation is blocked. Yacht and berth fields
* belong to Section 3 and may be left blank. */ * belong to Section 3 and may be left blank. */
interface EoiPrerequisites { interface EoiPrerequisites {
hasName: boolean; hasName: boolean;
hasEmail: boolean; hasEmail: boolean;
hasAddress: boolean; hasAddress: boolean;
/** Optional info-only checks. Generation proceeds without them. */ /** Optional - info-only checks. Generation proceeds without them. */
hasYacht: boolean; hasYacht: boolean;
hasBerth: boolean; hasBerth: boolean;
} }
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">
Optional (Section 3 left blank if absent) Optional (Section 3 - left blank if absent)
</p> </p>
{OPTIONAL_LABELS.map(({ key, label }) => ( {OPTIONAL_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm"> <div key={key} className="flex items-center gap-3 text-sm">

View File

@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
</p> </p>
) : null} ) : null}
{/* Amount prominent */} {/* Amount - prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground"> <p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted} {amountFormatted}
</p> </p>

View File

@@ -72,7 +72,7 @@ export function getExpenseColumns({
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{row.original.establishmentName ?? ''} {row.original.establishmentName ?? '-'}
</Link> </Link>
), ),
}, },
@@ -113,7 +113,7 @@ export function getExpenseColumns({
header: 'Category', header: 'Category',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const cat = getValue() as string | null; const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>; if (!cat) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="capitalize text-xs"> <Badge variant="outline" className="capitalize text-xs">
{cat.replace(/_/g, ' ')} {cat.replace(/_/g, ' ')}

View File

@@ -146,19 +146,19 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardContent className="grid grid-cols-2 gap-4 text-sm"> <CardContent className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="text-muted-foreground">Category</span> <span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? ''}</p> <p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Payment Method</span> <span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? ''}</p> <p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Payer</span> <span className="text-muted-foreground">Payer</span>
<p className="mt-0.5">{expense.payer ?? ''}</p> <p className="mt-0.5">{expense.payer ?? '-'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Description</span> <span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? ''}</p> <p className="mt-0.5">{expense.description ?? '-'}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -30,7 +30,7 @@ interface InlineStagePickerProps {
/** /**
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker * Click-to-change stage chip. Replaces the modal-based InterestStagePicker
* for inline editing user clicks the chip, picks a new stage from the * for inline editing - user clicks the chip, picks a new stage from the
* popover (with optional reason), commits in one click. The popover stays * popover (with optional reason), commits in one click. The popover stays
* compact: a small reason field above the stage list, and clicking any stage * compact: a small reason field above the stage list, and clicking any stage
* fires the mutation immediately. * fires the mutation immediately.
@@ -140,7 +140,7 @@ export function InlineStagePicker({
isCurrent && 'font-medium', isCurrent && 'font-medium',
)} )}
> >
{/* Colored chip (mirrors the inline stage badge) turns {/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */} the picker into a visual scan rather than just a list. */}
<span <span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])} className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}

View File

@@ -78,7 +78,7 @@ export function getInterestColumns({
className="truncate font-medium text-primary hover:underline" className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{row.original.clientName ?? ''} {row.original.clientName ?? '-'}
</Link> </Link>
{notesCount > 0 ? ( {notesCount > 0 ? (
<span <span
@@ -99,7 +99,7 @@ export function getInterestColumns({
header: 'Berth', header: 'Berth',
cell: ({ row }) => { cell: ({ row }) => {
if (!row.original.berthId || !row.original.berthMooringNumber) { if (!row.original.berthId || !row.original.berthMooringNumber) {
return <span className="text-muted-foreground"></span>; return <span className="text-muted-foreground">-</span>;
} }
return ( return (
<Link <Link
@@ -150,7 +150,7 @@ export function getInterestColumns({
header: 'Category', header: 'Category',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const cat = getValue() as string | null; const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>; if (!cat) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="text-xs capitalize"> <Badge variant="outline" className="text-xs capitalize">
{CATEGORY_LABELS[cat] ?? cat} {CATEGORY_LABELS[cat] ?? cat}
@@ -164,7 +164,7 @@ export function getInterestColumns({
header: 'Source', header: 'Source',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const source = getValue() as string | null; const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>; if (!source) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{SOURCE_LABELS[source] ?? source} {SOURCE_LABELS[source] ?? source}
@@ -178,7 +178,7 @@ export function getInterestColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const rowTags = row.original.tags ?? []; const rowTags = row.original.tags ?? [];
if (rowTags.length === 0) return <span className="text-muted-foreground"></span>; if (rowTags.length === 0) return <span className="text-muted-foreground">-</span>;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{rowTags.slice(0, 3).map((tag) => ( {rowTags.slice(0, 3).map((tag) => (
@@ -203,7 +203,7 @@ export function getInterestColumns({
cell: ({ row }) => { cell: ({ row }) => {
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null; const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
if (!lastIso) { if (!lastIso) {
return <span className="text-muted-foreground text-sm"></span>; return <span className="text-muted-foreground text-sm">-</span>;
} }
const d = new Date(lastIso); const d = new Date(lastIso);
return ( return (

Some files were not shown because too many files have changed in this diff Show More