Compare commits
14 Commits
b703684285
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8699f81879 | ||
|
|
d62822c284 | ||
|
|
089f4a67a4 | ||
|
|
77ad10ced1 | ||
|
|
e598cc0708 | ||
|
|
f5772ce318 | ||
|
|
49d34e00c8 | ||
|
|
c612bbdfd9 | ||
|
|
872c75f1a1 | ||
|
|
c45aac551d | ||
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 |
123
docs/operations/outbound-comms-safety.md
Normal file
123
docs/operations/outbound-comms-safety.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Outbound communications safety net
|
||||
|
||||
**Last reviewed:** 2026-05-03
|
||||
**Owner:** matt@portnimara.com
|
||||
|
||||
This doc enumerates every channel through which the CRM can produce
|
||||
outbound communication (email, document signing, webhooks) and describes
|
||||
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
|
||||
single environment flip pauses **all** outbound traffic, so a production
|
||||
data import, dedup migration dry-run, or staging environment can run
|
||||
against real data without anyone getting paged or spammed.
|
||||
|
||||
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
|
||||
> all outbound communication is rerouted there or short-circuited. Unset
|
||||
> it in production.
|
||||
|
||||
---
|
||||
|
||||
## Channels
|
||||
|
||||
### 1. Direct email (`sendEmail`)
|
||||
|
||||
**Path:** `src/lib/email/index.ts` → `sendEmail()` → nodemailer SMTP transport.
|
||||
|
||||
**Safety:** YES — covered.
|
||||
|
||||
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
|
||||
to the redirect address and prefixes the subject with
|
||||
`[redirected from <orig>]`. The original recipient is logged.
|
||||
|
||||
**Call sites** (all flow through `sendEmail`, so all are covered):
|
||||
|
||||
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
|
||||
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
|
||||
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
|
||||
as attachments (the PDF body is generated locally; the email itself
|
||||
goes through SMTP)
|
||||
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
|
||||
in the in-app UI
|
||||
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
|
||||
|
||||
### 2. Documenso e-signature recipients
|
||||
|
||||
**Path:** `src/lib/services/documenso-client.ts` → `createDocument()` /
|
||||
`generateDocumentFromTemplate()` → Documenso REST API.
|
||||
|
||||
**Safety:** YES — covered as of 2026-05-03.
|
||||
|
||||
Documenso's own server sends the signing-request email on our behalf.
|
||||
We can't intercept that at the SMTP layer because it's external. The
|
||||
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
|
||||
`createDocument` rewrites every recipient's email to the redirect
|
||||
address and prefixes the recipient name with `(was: <orig email>)` so
|
||||
the doc is still traceable to its intended recipient.
|
||||
`generateDocumentFromTemplate` does the same for both shapes the
|
||||
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
|
||||
v2.x `recipients` array).
|
||||
|
||||
The redirect happens **before** the API call, so even if Documenso has
|
||||
its own retry logic the original email never leaves our process.
|
||||
|
||||
### 3. Webhooks (outbound to user-configured URLs)
|
||||
|
||||
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
|
||||
|
||||
**Safety:** YES — covered as of 2026-05-03.
|
||||
|
||||
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
|
||||
before the HTTP call. The delivery row is marked `dead_letter` with a
|
||||
human-readable reason so it's still visible in the deliveries listing.
|
||||
The SSRF guard remains in place independently.
|
||||
|
||||
### 4. WhatsApp / phone deep-links
|
||||
|
||||
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
|
||||
client / interest detail headers.
|
||||
|
||||
**Safety:** N/A — user-initiated only.
|
||||
|
||||
These are deep links the user explicitly clicks. No automated dispatch.
|
||||
A deep link click opens the user's WhatsApp / phone app, which is the
|
||||
intended interaction. No safety net needed.
|
||||
|
||||
### 5. SMS
|
||||
|
||||
Not implemented. The `interests.preferredContactMethod` enum includes
|
||||
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
|
||||
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
|
||||
the same way `sendEmail` does — log the original number, drop the
|
||||
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist before importing real data
|
||||
|
||||
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
|
||||
- [ ] Restart dev server (or worker) so the new env is picked up — env
|
||||
vars are read at import time in some paths.
|
||||
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
|
||||
or similar. Confirm subject is prefixed with `[redirected from ...]`.
|
||||
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
|
||||
shows the redirect address as recipient (not the real client email).
|
||||
- [ ] If any webhooks are configured, trigger an event that fires one and
|
||||
confirm the delivery is recorded as `dead_letter` with the
|
||||
"EMAIL_REDIRECT_TO is set" reason.
|
||||
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
|
||||
`--apply` step is what creates real records but emails/webhooks are
|
||||
still gated by the redirect env.
|
||||
|
||||
## Production cutover
|
||||
|
||||
When ready to go live:
|
||||
|
||||
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
|
||||
to a sandbox address.
|
||||
2. Verify the snapshot looks right (counts, client coverage).
|
||||
3. Unset `EMAIL_REDIRECT_TO` in the production env.
|
||||
4. Restart the app + worker.
|
||||
5. Run the migration with `--apply`. From this point forward, real
|
||||
recipients will receive real comms.
|
||||
|
||||
If you ever need to re-pause outbound (e.g. handling a security incident,
|
||||
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.
|
||||
135
scripts/backfill-legacy-lead-source.ts
Normal file
135
scripts/backfill-legacy-lead-source.ts
Normal 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);
|
||||
});
|
||||
144
scripts/backfill-phone-e164.ts
Normal file
144
scripts/backfill-phone-e164.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||||
* contacts where it's null or empty.
|
||||
*
|
||||
* The legacy seed (and pre-normalization production data) stored phone
|
||||
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||||
* is what every UI surface and dedup matcher reads. This script runs the
|
||||
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||||
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||||
* form back.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||||
*
|
||||
* The dry-run report prints, for each unparseable row, the contact id +
|
||||
* raw value so you can hand-clean before re-running.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientContacts } from '@/lib/db/schema/clients';
|
||||
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
interface PhoneRow {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string | null;
|
||||
valueCountry: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||||
console.log('');
|
||||
|
||||
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||||
// `value_e164` null/empty.
|
||||
const rows: PhoneRow[] = await db
|
||||
.select({
|
||||
id: clientContacts.id,
|
||||
channel: clientContacts.channel,
|
||||
value: clientContacts.value,
|
||||
valueCountry: clientContacts.valueCountry,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||||
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(` found ${rows.length} candidate rows`);
|
||||
|
||||
let parsedFull = 0;
|
||||
let parsedE164Only = 0;
|
||||
let unparseable = 0;
|
||||
const updates: Array<{
|
||||
id: string;
|
||||
valueE164: string;
|
||||
valueCountry: CountryCode | null;
|
||||
}> = [];
|
||||
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.value) continue;
|
||||
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||||
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||||
|
||||
if (parsed1.e164 && parsed1.country) {
|
||||
// Both e164 + country resolved — best case.
|
||||
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||||
parsedFull++;
|
||||
} else if (parsed1.e164) {
|
||||
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||||
// fictional/reserved range — libphonenumber returns the e164 form
|
||||
// but refuses to assign a country). Still safe to write — the e164
|
||||
// is canonical. Country stays null.
|
||||
updates.push({
|
||||
id: row.id,
|
||||
valueE164: parsed1.e164,
|
||||
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||||
});
|
||||
parsedE164Only++;
|
||||
} else {
|
||||
fails.push({
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
reason: row.value.trim().startsWith('+')
|
||||
? 'has + prefix but parse failed'
|
||||
: 'no leading + and no country hint',
|
||||
});
|
||||
unparseable++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||||
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||||
console.log(' ✗ unparseable ', unparseable);
|
||||
console.log('');
|
||||
|
||||
if (fails.length > 0) {
|
||||
console.log('Failures (first 10):');
|
||||
for (const f of fails.slice(0, 10)) {
|
||||
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No updates to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Writing ${updates.length} updates...`);
|
||||
|
||||
for (const u of updates) {
|
||||
await db
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
valueE164: u.valueE164,
|
||||
valueCountry: u.valueCountry,
|
||||
})
|
||||
.where(eq(clientContacts.id, u.id));
|
||||
}
|
||||
|
||||
console.log(` ✓ wrote ${updates.length} rows`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
126
scripts/load-berths-to-port-nimara.ts
Normal file
126
scripts/load-berths-to-port-nimara.ts
Normal 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);
|
||||
});
|
||||
@@ -7,21 +7,30 @@
|
||||
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||
* 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
|
||||
* the apply phase — every client/interest belongs to one port).
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||
* [Not yet implemented — apply phase comes in a follow-up PR.]
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
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 { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||
@@ -31,6 +40,7 @@ interface CliArgs {
|
||||
apply: boolean;
|
||||
portSlug: string | null;
|
||||
reportDir: string | null;
|
||||
unsafeSkipRedirectCheck: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -39,6 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
apply: false,
|
||||
portSlug: null,
|
||||
reportDir: null,
|
||||
unsafeSkipRedirectCheck: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
@@ -46,6 +57,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = 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') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
@@ -64,20 +76,50 @@ function printHelp(): void {
|
||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||
No database writes.
|
||||
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||
Apply phase. (Not yet implemented.)
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
|
||||
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:
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write to the new DB. (Not yet supported.)
|
||||
--port-slug <slug> Port slug to attach to all imported entities.
|
||||
Defaults to the first available port if omitted.
|
||||
--report <dir> Path to a previously-generated report dir
|
||||
(only used by --apply).
|
||||
-h, --help Show this help.
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write rows to the DB.
|
||||
--port-slug <slug> Port slug to attach to all imported
|
||||
entities. Defaults to the first
|
||||
available port if omitted.
|
||||
--report <dir> Path to a previously-generated report
|
||||
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> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -87,13 +129,21 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.apply) {
|
||||
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.');
|
||||
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.');
|
||||
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
|
||||
// operator explicitly opts out (production cutover).
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Dry-run path ───────────────────────────────────────────────────────────
|
||||
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
|
||||
|
||||
console.log('[migrate] Loading NocoDB config…');
|
||||
const config = loadNocoDbConfig();
|
||||
@@ -110,8 +160,7 @@ async function main(): Promise<void> {
|
||||
console.log('[migrate] Running transform + dedup pipeline…');
|
||||
const plan = transformSnapshot(snapshot);
|
||||
|
||||
// Resolve output paths relative to the worktree root (the script itself
|
||||
// lives in scripts/; we want the .migration dir at the repo root).
|
||||
// Resolve output paths relative to the worktree root.
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const generatedAt = new Date().toISOString();
|
||||
@@ -120,7 +169,7 @@ async function main(): Promise<void> {
|
||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||
await writeReport(paths, plan, generatedAt);
|
||||
|
||||
// ── Console summary ──────────────────────────────────────────────────────
|
||||
// ── Plan summary ─────────────────────────────────────────────────────────
|
||||
const s = plan.stats;
|
||||
console.log('');
|
||||
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(` ${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(
|
||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||
);
|
||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||
console.log('');
|
||||
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('');
|
||||
}
|
||||
|
||||
|
||||
108
scripts/smoke-test-redirect.ts
Normal file
108
scripts/smoke-test-redirect.ts
Normal 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);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
label: 'Default signature (HTML)',
|
||||
description: 'Appended to the bottom of system-generated emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p>—<br>The Port Nimara team</p>',
|
||||
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'smtp_pass_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',
|
||||
defaultValue: '',
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Users,
|
||||
UsersRound,
|
||||
Webhook,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
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.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'website-analytics',
|
||||
label: 'Website analytics (Umami)',
|
||||
description: 'Per-port Umami URL, API token, and Website ID.',
|
||||
icon: Globe,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 port’s 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>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
/**
|
||||
* Route-level loading UI for the client detail page. Renders while the
|
||||
* 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.
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<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="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
|
||||
}, [setChrome]);
|
||||
|
||||
// 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.
|
||||
const { data: prefilledInterest } = useQuery<{
|
||||
data: {
|
||||
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
|
||||
{prefilledInterest?.data
|
||||
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
||||
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 linked interest to "Deposit 10%".'}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
11
src/app/(dashboard)/[portSlug]/website-analytics/page.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/website-analytics/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
<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">
|
||||
<Sidebar
|
||||
portRoles={portRoles}
|
||||
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||
email: session.user.email,
|
||||
}}
|
||||
ports={ports}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<Topbar
|
||||
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
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>
|
||||
|
||||
{/* Mobile shell — hidden by CSS on desktop */}
|
||||
{/* Mobile shell - hidden by CSS on desktop */}
|
||||
<MobileLayout>{children}</MobileLayout>
|
||||
</SocketProvider>
|
||||
</PermissionsProvider>
|
||||
|
||||
@@ -12,14 +12,10 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function PortalLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
// This layout wraps all portal routes including login/verify
|
||||
// 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.
|
||||
const session = await getPortalSession().catch(() => null);
|
||||
|
||||
@@ -42,17 +38,11 @@ export default async function PortalLayout({
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{session && (
|
||||
<>
|
||||
<PortalHeader
|
||||
portName={portName}
|
||||
portLogoUrl={portLogoUrl}
|
||||
clientName={clientName}
|
||||
/>
|
||||
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
|
||||
<PortalNav />
|
||||
</>
|
||||
)}
|
||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
|
||||
{children}
|
||||
</main>
|
||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
|
||||
<PasswordSetForm
|
||||
endpoint="/api/portal/auth/activate"
|
||||
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"
|
||||
successDescription="You can now sign in with your new password."
|
||||
submitLabel="Activate account"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
|
||||
<span className="font-medium text-gray-900">General Interest</span>
|
||||
)}
|
||||
{interest.berthArea && (
|
||||
<span className="text-sm text-gray-400">— {interest.berthArea}</span>
|
||||
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
|
||||
)}
|
||||
</div>
|
||||
{interest.leadCategory && (
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||
{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>
|
||||
<p className="text-sm text-gray-500">
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { QueryProvider } from '@/providers/query-provider';
|
||||
import { PortProvider } from '@/providers/port-provider';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
|
||||
* topbar — the scanner is its own contained surface. Adds the PWA
|
||||
* manifest link + theme color so iOS/Android pick up "Add to Home
|
||||
* Screen". Auth check matches the dashboard layout so unauthorized
|
||||
* users still bounce to /login.
|
||||
* topbar - the scanner is its own contained surface. PWA manifest +
|
||||
* iOS web-app meta tags are emitted via Next.js's metadata/viewport
|
||||
* exports so React doesn't try to render a second `<head>` mid-tree
|
||||
* (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({
|
||||
children,
|
||||
params,
|
||||
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
|
||||
<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>
|
||||
<PortProvider ports={[port]} defaultPortId={port.id}>
|
||||
<div className="min-h-[100dvh] bg-background">{children}</div>
|
||||
</PortProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
|
||||
const manifest = {
|
||||
name: `${portName} — Scanner`,
|
||||
name: `${portName} - Scanner`,
|
||||
short_name: 'Scanner',
|
||||
description: `Capture and submit expense receipts for ${portName}.`,
|
||||
start_url: `/${portSlug}/scan`,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
import { ScanShell } from '@/components/scan/scan-shell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt — Port Nimara',
|
||||
title: 'Scan receipt - Port Nimara',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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
|
||||
* 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).
|
||||
*
|
||||
* For deep dependency checks, hit `/api/ready` instead.
|
||||
|
||||
@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
||||
// Keep the helper aligned with that.
|
||||
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 +
|
||||
// membership, all inside a single transaction.
|
||||
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';
|
||||
|
||||
// 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.
|
||||
let berthId: string | null = null;
|
||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||
|
||||
@@ -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
|
||||
* `residential_clients` row and an opening `residential_interests` row in a
|
||||
* 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_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).
|
||||
void sendResidentialNotifications({
|
||||
portId,
|
||||
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
|
||||
});
|
||||
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.
|
||||
const recipientsRow = await db.query.systemSettings.findFirst({
|
||||
where: and(
|
||||
|
||||
177
src/app/api/public/website-inquiries/route.ts
Normal file
177
src/app/api/public/website-inquiries/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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
|
||||
* load balancer until the next probe succeeds; it should not trigger a
|
||||
* pod restart (that's what `/api/health` is for).
|
||||
|
||||
@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||
* exercised by the realapi socket fanout test.
|
||||
*
|
||||
* 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) => {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
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.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
|
||||
.map((p) => {
|
||||
const a = clientById.get(p.clientAId);
|
||||
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.
|
||||
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||
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
|
||||
// port. Listing it cross-tenant would let a port-A director
|
||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||
|
||||
@@ -13,7 +13,7 @@ const schema = z.object({
|
||||
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.
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req) => {
|
||||
|
||||
@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
|
||||
* POST /api/v1/admin/templates/preview
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Body:
|
||||
* content: TipTap JSON document
|
||||
* sampleData?: Record<string, string> — variable substitutions
|
||||
* sampleData?: Record<string, string> - variable substitutions
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
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.
|
||||
*/
|
||||
function substituteInDoc(
|
||||
node: TipTapNode,
|
||||
data: Record<string, string>,
|
||||
): TipTapNode {
|
||||
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
|
||||
if (node.type === 'text' && node.text) {
|
||||
return { ...node, text: substituteVariables(node.text, data) };
|
||||
}
|
||||
|
||||
24
src/app/api/v1/admin/umami/test/route.ts
Normal file
24
src/app/api/v1/admin/umami/test/route.ts
Normal 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 } });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getRevenueBreakdown,
|
||||
type DateRange,
|
||||
type MetricBase,
|
||||
type PresetDateRange,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
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)) {
|
||||
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);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
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
|
||||
// handler after the body is parsed.
|
||||
export const PATCH = withAuth(patchHandler);
|
||||
|
||||
@@ -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(
|
||||
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getBerthOptions } from '@/lib/services/berths.service';
|
||||
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(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@ const inviteSchema = z.object({
|
||||
*
|
||||
* 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, returns 409 — the admin can resend the activation via
|
||||
* email, returns 409 - the admin can resend the activation via
|
||||
* ?action=resend.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
|
||||
const nameResult = rawName ? normalizeName(rawName) : null;
|
||||
|
||||
// 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) {
|
||||
return NextResponse.json({ data: [] });
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
|
||||
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).
|
||||
const useful = matches.filter((m) => m.confidence !== 'low');
|
||||
if (useful.length === 0) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
|
||||
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
// would push it past the cap. Soft-cap warnings ride along on the
|
||||
// success response so the UI can show a banner without blocking.
|
||||
@@ -99,7 +99,7 @@ export const POST = withAuth(
|
||||
});
|
||||
} catch (err) {
|
||||
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({
|
||||
data: {
|
||||
parsed: EMPTY,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const POST = withAuth(
|
||||
try {
|
||||
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
|
||||
.replace(/\x00/g, '')
|
||||
.replace(/\.\.\//g, '')
|
||||
|
||||
@@ -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(
|
||||
withPermission('interests', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
|
||||
@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
won: 'Won',
|
||||
lost_other_marina: 'Lost — went to another marina',
|
||||
lost_unqualified: 'Lost — unqualified',
|
||||
lost_no_response: 'Lost — no response',
|
||||
lost_other_marina: 'Lost - went to another marina',
|
||||
lost_unqualified: 'Lost - unqualified',
|
||||
lost_no_response: 'Lost - no response',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ function buildAuditDescription(
|
||||
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||
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') {
|
||||
@@ -200,9 +200,9 @@ function buildAuditDescription(
|
||||
const reason = (newValue.reason as string | undefined) ?? '';
|
||||
const auto = userId === 'system';
|
||||
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) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
|
||||
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);
|
||||
|
||||
return NextResponse.json(results);
|
||||
|
||||
113
src/app/api/v1/website-analytics/route.ts
Normal file
113
src/app/api/v1/website-analytics/route.ts
Normal 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 });
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.info({ signatureHash }, 'Duplicate Documenso webhook — skipping');
|
||||
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
@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 {
|
||||
background-image: repeating-linear-gradient(
|
||||
135deg,
|
||||
@@ -134,7 +134,7 @@
|
||||
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
||||
* 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
|
||||
* be overridden by `display: block` (same specificity, later cascade).
|
||||
*/
|
||||
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function AuditLogList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Filter state — debounce text inputs.
|
||||
// Filter state - debounce text inputs.
|
||||
const [search, setSearch] = useState('');
|
||||
const [entityType, setEntityType] = useState<string>('all');
|
||||
const [action, setAction] = useState<string>('all');
|
||||
@@ -215,7 +215,7 @@ export function AuditLogList() {
|
||||
</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
|
||||
title="Audit Log"
|
||||
eyebrow="Admin"
|
||||
description="Every state change in this port — fully searchable."
|
||||
description="Every state change in this port - fully searchable."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
|
||||
@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CustomFieldForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
field,
|
||||
onSuccess,
|
||||
}: CustomFieldFormProps) {
|
||||
export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
|
||||
const isEdit = !!field;
|
||||
|
||||
// Form state
|
||||
@@ -72,9 +67,7 @@ export function CustomFieldForm({
|
||||
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
||||
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
||||
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
||||
const [selectOptions, setSelectOptions] = useState<string[]>(
|
||||
field?.selectOptions ?? [],
|
||||
);
|
||||
const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
|
||||
const [newOption, setNewOption] = useState('');
|
||||
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
||||
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
||||
@@ -169,13 +162,11 @@ export function CustomFieldForm({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
||||
{/* Entity Type — create only */}
|
||||
{/* Entity Type - create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
||||
{isEdit ? (
|
||||
@@ -198,7 +189,7 @@ export function CustomFieldForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field Name — create only */}
|
||||
{/* Field Name - create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-name">
|
||||
Field Name
|
||||
@@ -232,7 +223,7 @@ export function CustomFieldForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Type — create only */}
|
||||
{/* Field Type - create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-type">Field Type</Label>
|
||||
{isEdit ? (
|
||||
@@ -260,7 +251,7 @@ export function CustomFieldForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Select Options — visible when fieldType = 'select' */}
|
||||
{/* Select Options - visible when fieldType = 'select' */}
|
||||
{fieldType === 'select' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Options</Label>
|
||||
@@ -302,11 +293,7 @@ export function CustomFieldForm({
|
||||
{/* Is Required */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cf-is-required">Required field</Label>
|
||||
<Switch
|
||||
id="cf-is-required"
|
||||
checked={isRequired}
|
||||
onCheckedChange={setIsRequired}
|
||||
/>
|
||||
<Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||
|
||||
@@ -61,20 +55,13 @@ interface TemplateFormProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function TemplateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateFormProps) {
|
||||
export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
|
||||
const isEdit = !!template;
|
||||
|
||||
const [name, setName] = useState(template?.name ?? '');
|
||||
const [type, setType] = useState(template?.templateType ?? 'other');
|
||||
const [contentJson, setContentJson] = useState(
|
||||
template?.content
|
||||
? JSON.stringify(template.content, null, 2)
|
||||
: EMPTY_DOC,
|
||||
template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -86,7 +73,7 @@ export function TemplateForm({
|
||||
setJsonError(null);
|
||||
return true;
|
||||
} catch {
|
||||
setJsonError('Invalid JSON — check syntax.');
|
||||
setJsonError('Invalid JSON - check syntax.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -115,8 +102,7 @@ export function TemplateForm({
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Something went wrong';
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -127,9 +113,7 @@ export function TemplateForm({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{isEdit ? 'Edit Template' : 'New Document Template'}
|
||||
</SheetTitle>
|
||||
<SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||
@@ -145,7 +129,7 @@ export function TemplateForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type — only on create */}
|
||||
{/* Type - only on create */}
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-type">Document Type</Label>
|
||||
@@ -166,15 +150,11 @@ export function TemplateForm({
|
||||
|
||||
{/* TipTap JSON Content */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-content">
|
||||
Document Content (TipTap JSON)
|
||||
</Label>
|
||||
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste or edit TipTap JSON. Use{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{'{{variable.key}}'}
|
||||
</code>{' '}
|
||||
tokens for dynamic content.
|
||||
<code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
|
||||
dynamic content.
|
||||
</p>
|
||||
<textarea
|
||||
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"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-destructive">{jsonError}</p>
|
||||
)}
|
||||
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Available Variables Reference */}
|
||||
@@ -200,19 +178,15 @@ export function TemplateForm({
|
||||
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<div key={v.key} className="text-xs">
|
||||
<code className="rounded bg-muted px-1">
|
||||
{`{{${v.key}}}`}
|
||||
</code>{' '}
|
||||
<span className="text-muted-foreground">— {v.label}</span>
|
||||
<code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
|
||||
<span className="text-muted-foreground">- {v.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{error && (
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter>
|
||||
@@ -225,11 +199,7 @@ export function TemplateForm({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !!jsonError}>
|
||||
{loading
|
||||
? 'Saving…'
|
||||
: isEdit
|
||||
? 'Save Changes'
|
||||
: 'Create Template'}
|
||||
{loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TemplateForm } from './template-form';
|
||||
import { TemplateVersionHistory } from './template-version-history';
|
||||
@@ -57,9 +52,7 @@ export function TemplateList() {
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: AdminTemplate[] }>(
|
||||
'/api/v1/admin/templates',
|
||||
);
|
||||
const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
|
||||
setTemplates(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -122,9 +115,7 @@ export function TemplateList() {
|
||||
accessorKey: 'version',
|
||||
header: 'Version',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
v{row.original.version}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">v{row.original.version}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -151,10 +142,7 @@ export function TemplateList() {
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TemplatePreview
|
||||
content={row.original.content}
|
||||
templateName={row.original.name}
|
||||
/>
|
||||
<TemplatePreview content={row.original.content} templateName={row.original.name} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -177,9 +165,7 @@ export function TemplateList() {
|
||||
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
||||
onClick={() => handleToggleActive(row.original)}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{row.original.isActive ? 'Off' : 'On'}
|
||||
</span>
|
||||
<span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
@@ -233,9 +219,7 @@ export function TemplateList() {
|
||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
Version History — {historyTemplate?.name}
|
||||
</SheetTitle>
|
||||
<SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
{historyTemplate && (
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
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);
|
||||
|
||||
// Build sample data from TEMPLATE_VARIABLES examples
|
||||
const sampleData = Object.fromEntries(
|
||||
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
|
||||
);
|
||||
const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
|
||||
|
||||
async function handlePreview() {
|
||||
if (!content) {
|
||||
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Preview — {templateName}</DialogTitle>
|
||||
<DialogTitle>Preview - {templateName}</DialogTitle>
|
||||
{pdfBase64 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenInNewTab}
|
||||
className="mr-6"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Open in new tab
|
||||
</Button>
|
||||
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{pdfBase64 && !loading && (
|
||||
|
||||
@@ -117,7 +117,7 @@ export function InvitationsManager() {
|
||||
{invites.map((i) => (
|
||||
<tr key={i.id} className="border-t">
|
||||
<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">
|
||||
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
|
||||
</td>
|
||||
@@ -163,7 +163,7 @@ export function InvitationsManager() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
||||
Enable AI receipt parsing for this port
|
||||
</Label>
|
||||
<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
|
||||
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
||||
</p>
|
||||
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
||||
id={`apiKey-${scope}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
autoComplete="off"
|
||||
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
|
||||
placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
|
||||
@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
|
||||
};
|
||||
|
||||
function formatDate(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
if (!ts) return '-';
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
|
||||
}
|
||||
|
||||
function truncateReason(reason: string | undefined): string {
|
||||
if (!reason) return '—';
|
||||
if (!reason) return '-';
|
||||
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{total} total jobs — page {page} of {totalPages}
|
||||
{total} total jobs - page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -95,7 +95,7 @@ export function RoleList() {
|
||||
accessorKey: 'description',
|
||||
header: 'Description',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
|
||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -241,7 +241,14 @@ export function SettingsManager() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
||||
<div
|
||||
key={setting.key}
|
||||
// Stack label/description above the input on phone widths.
|
||||
// The previous flex row crushed the label column into a
|
||||
// narrow vertical stripe ("Inquiry / Contact / Email" wrapping
|
||||
// one word per line) while the input took the rest.
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
@@ -249,7 +256,7 @@ export function SettingsManager() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="w-64"
|
||||
className="w-full sm:w-64"
|
||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
@@ -283,7 +290,10 @@ export function SettingsManager() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
|
||||
<div key={setting.key} className="flex items-center justify-between gap-4">
|
||||
<div
|
||||
key={setting.key}
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||
@@ -291,7 +301,7 @@ export function SettingsManager() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-24"
|
||||
className="w-full sm:w-24"
|
||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
@@ -353,7 +363,7 @@ export function SettingsManager() {
|
||||
);
|
||||
void saveSetting(setting.key, parsed);
|
||||
} catch {
|
||||
// invalid JSON — do nothing
|
||||
// invalid JSON - do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</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 ? (
|
||||
<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 />
|
||||
|
||||
@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [webhookId, page]);
|
||||
|
||||
if (loading && deliveries.length === 0) {
|
||||
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{d.responseStatus ?? '—'}
|
||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
|
||||
<TableCell className="text-sm">{d.attempt}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{d.deliveredAt
|
||||
|
||||
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal file
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
export function AlertBell() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const [open, setOpen] = useState(false);
|
||||
// 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.
|
||||
// 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.
|
||||
const { data: count } = useAlertCount();
|
||||
const { data: list, isLoading } = useAlertList('open', open);
|
||||
useAlertRealtime();
|
||||
|
||||
@@ -22,11 +22,10 @@ export function AlertRail() {
|
||||
<section
|
||||
data-testid="alert-rail"
|
||||
aria-label="Active alerts"
|
||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
||||
// gives this rail a sibling column whose height it should match. On
|
||||
// mobile (single-column stack) there's no fixed-height context, so
|
||||
// forcing 100% height makes the section overflow / look stretched.
|
||||
className="flex flex-col gap-3 xl:h-full"
|
||||
// Natural height - the parent aside no longer forces 100% of the
|
||||
// dashboard grid row, so the rail can sit compactly under Reminders
|
||||
// without bleeding down into the Recent Activity panel below.
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<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)}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<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" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'mooringNumber',
|
||||
header: 'Mooring #',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.mooringNumber}</span>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'area',
|
||||
header: 'Area',
|
||||
cell: ({ row }) => row.original.area ?? '—',
|
||||
cell: ({ row }) => row.original.area ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { lengthM, widthM } = row.original;
|
||||
if (!lengthM && !widthM) return '—';
|
||||
if (!lengthM && !widthM) return '-';
|
||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||
},
|
||||
},
|
||||
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
header: 'Price',
|
||||
cell: ({ row }) => {
|
||||
const { price, priceCurrency } = row.original;
|
||||
if (!price) return '—';
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: priceCurrency || 'USD',
|
||||
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'tenureType',
|
||||
header: 'Tenure',
|
||||
cell: ({ row }) =>
|
||||
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
|
||||
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
|
||||
@@ -93,7 +93,7 @@ function SelectOrEmpty({
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
<SelectItem value={NONE}>-</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
href={`/${portSlug}/interests/${i.id}` as never}
|
||||
className="hover:text-brand"
|
||||
>
|
||||
{i.clientName ?? '—'}
|
||||
{i.clientName ?? '-'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
</Badge>
|
||||
</td>
|
||||
<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 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 className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{new Date(i.createdAt).toLocaleDateString()}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function BerthList() {
|
||||
title="Berths"
|
||||
description="View and manage berth allocations"
|
||||
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">
|
||||
|
||||
@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
<BerthInterestPulse berthId={berth.id} />
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function getClientColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
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 (
|
||||
<span className="text-sm">
|
||||
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
||||
@@ -86,7 +86,7 @@ export function getClientColumns({
|
||||
cell: ({ getValue }) => {
|
||||
const iso = getValue() as string | null;
|
||||
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',
|
||||
cell: ({ getValue }) => {
|
||||
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 (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
@@ -111,7 +111,7 @@ export function getClientColumns({
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.yachtCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{c}
|
||||
@@ -126,7 +126,7 @@ export function getClientColumns({
|
||||
cell: ({ row }) => {
|
||||
const c = row.original.companyCount ?? 0;
|
||||
return c === 0 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{c}
|
||||
@@ -140,7 +140,7 @@ export function getClientColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clientTags.slice(0, 3).map((tag) => (
|
||||
|
||||
@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
|
||||
|
||||
function formatSince(startDate: string | Date): string {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
|
||||
Primary
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
)}
|
||||
</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. */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
</SheetHeader>
|
||||
|
||||
<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
|
||||
an existing client when one matches. The user can
|
||||
attach the new interest to that client instead of
|
||||
|
||||
@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
|
||||
}) {
|
||||
// Pin the most recently selected interest so the drawer stays populated
|
||||
// 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.
|
||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||
if (interest && interest !== pinned) setPinned(interest);
|
||||
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
|
||||
</DrawerHeader>
|
||||
|
||||
<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
|
||||
across surfaces. */}
|
||||
{stage ? (
|
||||
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
|
||||
</div>
|
||||
) : 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
|
||||
the pipeline stage so seed data without per-step dates still
|
||||
renders correctly. The full milestone columns + per-step
|
||||
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
|
||||
</div>
|
||||
</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
|
||||
missing so the drawer scales from sparse seed data to full
|
||||
records without empty placeholders. */}
|
||||
|
||||
@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
||||
interface PipelineSummaryProps {
|
||||
clientId: string;
|
||||
/**
|
||||
* `hero` — single-line pulse for the detail header (highest active stage only).
|
||||
* `panel` — compact list of every active interest, for the Overview tab.
|
||||
* `hero` - single-line pulse for the detail header (highest active stage only).
|
||||
* `panel` - compact list of every active interest, for the Overview tab.
|
||||
*/
|
||||
variant?: 'hero' | 'panel';
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
|
||||
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{y.hullNumber ?? '—'}</TableCell>
|
||||
<TableCell>{y.hullNumber ?? '-'}</TableCell>
|
||||
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -225,10 +225,10 @@ function ContactRow({
|
||||
|
||||
{/* Bottom / right: tag + actions.
|
||||
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
|
||||
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;
|
||||
neither makes sense without a value. The trash icon stays
|
||||
so the user can clean up the empty entry.
|
||||
|
||||
@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
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.
|
||||
setDismissed(false);
|
||||
}, 300);
|
||||
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
|
||||
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{isHigh
|
||||
? 'This looks like an existing client'
|
||||
: 'Possible match — check before creating'}
|
||||
: 'Possible match - check before creating'}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Export queued — refresh in ~30 seconds');
|
||||
toast.success('Export queued - refresh in ~30 seconds');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
setEmailOverride('');
|
||||
},
|
||||
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
Email the bundle when ready
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sends a 7-day signed download link to the client's primary email — or to the
|
||||
Sends a 7-day signed download link to the client's primary email - or to the
|
||||
override below.
|
||||
</p>
|
||||
{emailToClient ? (
|
||||
|
||||
@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
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)) {
|
||||
msg = 'This membership already exists (same client + role + start date).';
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function getCompanyColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
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>;
|
||||
},
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export function getCompanyColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
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>;
|
||||
},
|
||||
},
|
||||
@@ -98,7 +98,7 @@ export function getCompanyColumns({
|
||||
size: 88,
|
||||
cell: ({ row }) => {
|
||||
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>;
|
||||
},
|
||||
},
|
||||
@@ -109,7 +109,7 @@ export function getCompanyColumns({
|
||||
size: 88,
|
||||
cell: ({ row }) => {
|
||||
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>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateCompanyInput) => {
|
||||
if (isEdit) {
|
||||
// updateCompanySchema omits tagIds — strip them from PATCH body.
|
||||
// updateCompanySchema omits tagIds - strip them from PATCH body.
|
||||
const { tagIds: _tIds, ...rest } = data;
|
||||
void _tIds;
|
||||
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
||||
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
value={watch('incorporationCountryIso')}
|
||||
onChange={(iso) => {
|
||||
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);
|
||||
}}
|
||||
data-testid="company-incorp-country"
|
||||
|
||||
@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString();
|
||||
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
||||
</TableCell>
|
||||
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
||||
{m.roleDetail ?? '—'}
|
||||
{m.roleDetail ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(m.startDate)}</TableCell>
|
||||
<TableCell>
|
||||
{m.endDate ? (
|
||||
formatDate(m.endDate)
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
||||
Primary
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
function formatDimensions(y: OwnedYachtRow): string | null {
|
||||
if (y.lengthFt || y.widthFt) {
|
||||
const length = y.lengthFt ?? '—';
|
||||
const width = y.widthFt ?? '—';
|
||||
const length = y.lengthFt ?? '-';
|
||||
const width = y.widthFt ?? '-';
|
||||
return `${length} × ${width} ft`;
|
||||
}
|
||||
if (y.lengthM || y.widthM) {
|
||||
const length = y.lengthM ?? '—';
|
||||
const width = y.widthM ?? '—';
|
||||
const length = y.lengthM ?? '-';
|
||||
const width = y.widthM ?? '-';
|
||||
return `${length} × ${width} m`;
|
||||
}
|
||||
return null;
|
||||
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
|
||||
{dims ? (
|
||||
<span className="text-sm">{dims}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{y.hullNumber ? (
|
||||
<span className="text-sm">{y.hullNumber}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
<InlineCountryField
|
||||
value={company.incorporationCountryIso}
|
||||
onSave={async (iso) => {
|
||||
// Wipe subdivision when country flips — codes are country-scoped.
|
||||
// Wipe subdivision when country flips - codes are country-scoped.
|
||||
await mutation.mutateAsync({
|
||||
incorporationCountryIso: iso,
|
||||
incorporationSubdivisionIso: null,
|
||||
@@ -175,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
variant="textarea"
|
||||
value={company.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes — click to add"
|
||||
emptyText="No notes - click to add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function ActivityFeedInner() {
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No recent activity yet — your team's actions (interests created, stages changed,
|
||||
No recent activity yet - your team's actions (interests created, stages changed,
|
||||
invoices sent) will appear here.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { KpiCardsWithBoundary } from './kpi-cards';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
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',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 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() {
|
||||
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({
|
||||
'interest:stageChanged': [
|
||||
['analytics', 'pipeline_funnel', range],
|
||||
['analytics', 'lead_source_attribution', range],
|
||||
['analytics', 'pipeline_funnel'],
|
||||
['analytics', 'lead_source_attribution'],
|
||||
['dashboard', 'kpis'],
|
||||
],
|
||||
'client:created': [['dashboard', 'kpis']],
|
||||
'berth:statusChanged': [
|
||||
['analytics', 'occupancy_timeline', range],
|
||||
['analytics', 'occupancy_timeline'],
|
||||
['dashboard', 'kpis'],
|
||||
],
|
||||
});
|
||||
@@ -44,8 +69,8 @@ export function DashboardShell() {
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
eyebrow="Overview"
|
||||
description="Live snapshot of your marina activity"
|
||||
kpiLine={<span>{RANGE_LABELS[range]}</span>}
|
||||
description={`Live snapshot of ${portName} activity`}
|
||||
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||
variant="gradient"
|
||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
@@ -54,7 +79,12 @@ export function DashboardShell() {
|
||||
<KpiCardsWithBoundary />
|
||||
</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">
|
||||
<WidgetErrorBoundary>
|
||||
<PipelineFunnelChart range={range} />
|
||||
@@ -70,6 +100,11 @@ export function DashboardShell() {
|
||||
</WidgetErrorBoundary>
|
||||
</div>
|
||||
<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>
|
||||
<MyRemindersRail />
|
||||
</WidgetErrorBoundary>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: DateRange;
|
||||
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OPTIONS: Array<{ value: DateRange; label: string }> = [
|
||||
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7d', label: '7d' },
|
||||
{ value: '30d', label: '30d' },
|
||||
{ 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) {
|
||||
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 (
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map((opt) => {
|
||||
const active = opt.value === value;
|
||||
{PRESETS.map((opt) => {
|
||||
const active = !isCustom && opt.value === value;
|
||||
return (
|
||||
<Button
|
||||
key={opt.value}
|
||||
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { KPITile } from '@/components/ui/kpi-tile';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
|
||||
}
|
||||
|
||||
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>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryKey: ['dashboard', 'kpis', portId],
|
||||
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
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) {
|
||||
@@ -62,22 +71,22 @@ export function KpiCards() {
|
||||
}> = [
|
||||
{
|
||||
label: 'Total Clients',
|
||||
value: isError ? '—' : String(data?.totalClients ?? 0),
|
||||
value: isError ? '-' : String(data?.totalClients ?? 0),
|
||||
accent: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Active Interests',
|
||||
value: isError ? '—' : String(data?.activeInterests ?? 0),
|
||||
value: isError ? '-' : String(data?.activeInterests ?? 0),
|
||||
accent: 'teal',
|
||||
},
|
||||
{
|
||||
label: 'Pipeline Value',
|
||||
value: isError ? '—' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
||||
value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
||||
accent: 'success',
|
||||
},
|
||||
{
|
||||
label: 'Occupancy Rate',
|
||||
value: isError ? '—' : formatPercent(data?.occupancyRate ?? 0),
|
||||
value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
|
||||
accent: 'purple',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
|
||||
) : !slices.length ? (
|
||||
<EmptyState
|
||||
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
|
||||
|
||||
@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
|
||||
/**
|
||||
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||
* 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.
|
||||
*/
|
||||
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
|
||||
return `/${portSlug}/reminders`;
|
||||
}
|
||||
|
||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
||||
// tall empty card.
|
||||
// Natural height - the parent dashboard grid uses `items-start` so the
|
||||
// aside column no longer forces this rail to fill the chart column's
|
||||
// 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 (
|
||||
<Card className="xl:h-full">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||
All caught up — no reminders.
|
||||
All caught up - no reminders.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
import type {
|
||||
DateRange,
|
||||
LeadSourceAttributionData,
|
||||
MetricBase,
|
||||
OccupancyTimelineData,
|
||||
@@ -18,12 +18,27 @@ interface MetricResponse<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) {
|
||||
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],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch<MetricResponse<T>>(
|
||||
`/api/v1/analytics?metric=${metric}&range=${range}`,
|
||||
`/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
79
src/components/dashboard/website-glance-tile.tsx
Normal file
79
src/components/dashboard/website-glance-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
|
||||
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
||||
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) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');
|
||||
|
||||
@@ -26,7 +26,7 @@ interface DocumentListProps {
|
||||
interestId?: string;
|
||||
clientId?: string;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
|
||||
};
|
||||
|
||||
const getSignerProgress = (doc: DocumentRow) => {
|
||||
if (!doc.signers) return '—';
|
||||
if (!doc.signers) return '-';
|
||||
const signed = doc.signers.filter((s) => s.status === 'signed').length;
|
||||
return `${signed}/${doc.signers.length} signed`;
|
||||
};
|
||||
|
||||
@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
||||
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
||||
</StatusPill>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '—'}
|
||||
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
||||
|
||||
@@ -22,14 +22,14 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
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
|
||||
* belong to Section 3 and may be left blank. */
|
||||
interface EoiPrerequisites {
|
||||
hasName: boolean;
|
||||
hasEmail: boolean;
|
||||
hasAddress: boolean;
|
||||
/** Optional — info-only checks. Generation proceeds without them. */
|
||||
/** Optional - info-only checks. Generation proceeds without them. */
|
||||
hasYacht: boolean;
|
||||
hasBerth: boolean;
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Optional (Section 3 — left blank if absent)
|
||||
Optional (Section 3 - left blank if absent)
|
||||
</p>
|
||||
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3 text-sm">
|
||||
|
||||
@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Amount — prominent */}
|
||||
{/* Amount - prominent */}
|
||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||
{amountFormatted}
|
||||
</p>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function getExpenseColumns({
|
||||
className="font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.establishmentName ?? '—'}
|
||||
{row.original.establishmentName ?? '-'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export function getExpenseColumns({
|
||||
header: 'Category',
|
||||
cell: ({ getValue }) => {
|
||||
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 (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{cat.replace(/_/g, ' ')}
|
||||
|
||||
@@ -146,19 +146,19 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-muted-foreground">Description</span>
|
||||
<p className="mt-0.5">{expense.description ?? '—'}</p>
|
||||
<p className="mt-0.5">{expense.description ?? '-'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user