Compare commits
16 Commits
d7ec2a8507
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8699f81879 | ||
|
|
d62822c284 | ||
|
|
089f4a67a4 | ||
|
|
77ad10ced1 | ||
|
|
e598cc0708 | ||
|
|
f5772ce318 | ||
|
|
49d34e00c8 | ||
|
|
c612bbdfd9 | ||
|
|
872c75f1a1 | ||
|
|
c45aac551d | ||
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 | ||
|
|
b703684285 | ||
|
|
a792d9a182 |
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,
|
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||||
* writes a report to .migration/<timestamp>/. NO database writes.
|
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||||
*
|
*
|
||||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug harbor-royale
|
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
|
||||||
* Same, but tags the planned writes with the named port (matters for
|
* Same, but tags the planned writes with the named port (matters for
|
||||||
* the apply phase — every client/interest belongs to one port).
|
* the apply phase — every client/interest belongs to one port).
|
||||||
*
|
*
|
||||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
|
||||||
* [Not yet implemented — apply phase comes in a follow-up PR.]
|
* Re-fetches NocoDB, re-transforms, then writes the planned rows
|
||||||
|
* into the target port via the idempotent `migration_source_links`
|
||||||
|
* ledger. Re-runs are safe — already-imported source IDs are skipped.
|
||||||
|
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
|
||||||
|
* `--unsafe-skip-redirect-check` is also passed.
|
||||||
*
|
*
|
||||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { applyPlan } from '@/lib/dedup/migration-apply';
|
||||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||||
@@ -31,6 +40,7 @@ interface CliArgs {
|
|||||||
apply: boolean;
|
apply: boolean;
|
||||||
portSlug: string | null;
|
portSlug: string | null;
|
||||||
reportDir: string | null;
|
reportDir: string | null;
|
||||||
|
unsafeSkipRedirectCheck: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]): CliArgs {
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
@@ -39,6 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
apply: false,
|
apply: false,
|
||||||
portSlug: null,
|
portSlug: null,
|
||||||
reportDir: null,
|
reportDir: null,
|
||||||
|
unsafeSkipRedirectCheck: false,
|
||||||
};
|
};
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const a = argv[i]!;
|
const a = argv[i]!;
|
||||||
@@ -46,6 +57,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (a === '--apply') args.apply = true;
|
else if (a === '--apply') args.apply = true;
|
||||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||||
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||||
|
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
|
||||||
else if (a === '-h' || a === '--help') {
|
else if (a === '-h' || a === '--help') {
|
||||||
printHelp();
|
printHelp();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -64,20 +76,50 @@ function printHelp(): void {
|
|||||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||||
No database writes.
|
No database writes.
|
||||||
|
|
||||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
|
||||||
Apply phase. (Not yet implemented.)
|
Re-fetches NocoDB, re-transforms, writes via migration_source_links
|
||||||
|
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
|
||||||
|
(unless --unsafe-skip-redirect-check is also passed).
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--dry-run Read NocoDB, write report only.
|
--dry-run Read NocoDB, write report only.
|
||||||
--apply Actually write to the new DB. (Not yet supported.)
|
--apply Actually write rows to the DB.
|
||||||
--port-slug <slug> Port slug to attach to all imported entities.
|
--port-slug <slug> Port slug to attach to all imported
|
||||||
Defaults to the first available port if omitted.
|
entities. Defaults to the first
|
||||||
--report <dir> Path to a previously-generated report dir
|
available port if omitted.
|
||||||
(only used by --apply).
|
--report <dir> Path to a previously-generated report
|
||||||
-h, --help Show this help.
|
dir (only used by --apply).
|
||||||
|
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
|
||||||
|
check. Only use in production cutover.
|
||||||
|
-h, --help Show this help.
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the target port: use the slug if provided, otherwise the first
|
||||||
|
* port found. Errors out cleanly if the slug doesn't match any port.
|
||||||
|
*/
|
||||||
|
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
|
||||||
|
if (slug) {
|
||||||
|
const [p] = await db
|
||||||
|
.select({ id: ports.id, slug: ports.slug })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.slug, slug))
|
||||||
|
.limit(1);
|
||||||
|
if (!p) {
|
||||||
|
console.error(`No port found with slug "${slug}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return { id: p.id, slug: p.slug };
|
||||||
|
}
|
||||||
|
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
|
||||||
|
if (!first) {
|
||||||
|
console.error('No ports exist in the target DB. Seed at least one port before applying.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return { id: first.id, slug: first.slug };
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
@@ -87,13 +129,21 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.apply) {
|
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
|
||||||
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.');
|
// operator explicitly opts out (production cutover).
|
||||||
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.');
|
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
|
||||||
|
console.error(
|
||||||
|
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
|
||||||
|
);
|
||||||
|
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
|
||||||
|
console.error(
|
||||||
|
'If you are running the production cutover and have read that doc, add ' +
|
||||||
|
'--unsafe-skip-redirect-check to override.',
|
||||||
|
);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dry-run path ───────────────────────────────────────────────────────────
|
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
|
||||||
|
|
||||||
console.log('[migrate] Loading NocoDB config…');
|
console.log('[migrate] Loading NocoDB config…');
|
||||||
const config = loadNocoDbConfig();
|
const config = loadNocoDbConfig();
|
||||||
@@ -110,8 +160,7 @@ async function main(): Promise<void> {
|
|||||||
console.log('[migrate] Running transform + dedup pipeline…');
|
console.log('[migrate] Running transform + dedup pipeline…');
|
||||||
const plan = transformSnapshot(snapshot);
|
const plan = transformSnapshot(snapshot);
|
||||||
|
|
||||||
// Resolve output paths relative to the worktree root (the script itself
|
// Resolve output paths relative to the worktree root.
|
||||||
// lives in scripts/; we want the .migration dir at the repo root).
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
const generatedAt = new Date().toISOString();
|
const generatedAt = new Date().toISOString();
|
||||||
@@ -120,7 +169,7 @@ async function main(): Promise<void> {
|
|||||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||||
await writeReport(paths, plan, generatedAt);
|
await writeReport(paths, plan, generatedAt);
|
||||||
|
|
||||||
// ── Console summary ──────────────────────────────────────────────────────
|
// ── Plan summary ─────────────────────────────────────────────────────────
|
||||||
const s = plan.stats;
|
const s = plan.stats;
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('=== Migration Plan Summary ===');
|
console.log('=== Migration Plan Summary ===');
|
||||||
@@ -129,12 +178,70 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||||
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||||
|
console.log(
|
||||||
|
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||||
);
|
);
|
||||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(` Full report: ${paths.summaryPath}`);
|
console.log(` Full report: ${paths.summaryPath}`);
|
||||||
|
|
||||||
|
if (args.dryRun) {
|
||||||
|
console.log('');
|
||||||
|
console.log('Dry-run complete. Re-run with --apply to write rows.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const port = await resolvePort(args.portSlug);
|
||||||
|
const applyId = randomUUID();
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
|
||||||
|
console.log(`[migrate] Apply id: ${applyId}`);
|
||||||
|
console.log('[migrate] Inserting…');
|
||||||
|
|
||||||
|
const applyStart = Date.now();
|
||||||
|
const result = await applyPlan(plan, { port, applyId });
|
||||||
|
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('=== Apply Result ===');
|
||||||
|
console.log(` Time: ${applyElapsed}s`);
|
||||||
|
console.log(
|
||||||
|
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
|
||||||
|
);
|
||||||
|
console.log(` Contacts: ${result.contactsInserted} inserted`);
|
||||||
|
console.log(` Addresses: ${result.addressesInserted} inserted`);
|
||||||
|
console.log(` Yachts: ${result.yachtsInserted} inserted`);
|
||||||
|
console.log(
|
||||||
|
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
|
||||||
|
);
|
||||||
|
console.log(` Signers: ${result.documentSignersInserted} inserted`);
|
||||||
|
console.log(
|
||||||
|
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
||||||
|
);
|
||||||
|
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
||||||
|
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log('Warnings:');
|
||||||
|
for (const w of result.warnings.slice(0, 20)) {
|
||||||
|
console.log(` - ${w}`);
|
||||||
|
}
|
||||||
|
if (result.warnings.length > 20) {
|
||||||
|
console.log(` … ${result.warnings.length - 20} more`);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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)',
|
label: 'Default signature (HTML)',
|
||||||
description: 'Appended to the bottom of system-generated emails.',
|
description: 'Appended to the bottom of system-generated emails.',
|
||||||
type: 'html',
|
type: 'html',
|
||||||
placeholder: '<p>—<br>The Port Nimara team</p>',
|
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
|
|||||||
{
|
{
|
||||||
key: 'smtp_pass_override',
|
key: 'smtp_pass_override',
|
||||||
label: 'SMTP password override',
|
label: 'SMTP password override',
|
||||||
description: 'Optional. Stored in plain text — only set when overriding env credentials.',
|
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
|
UsersRound,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -29,132 +31,192 @@ interface AdminSection {
|
|||||||
icon: typeof Settings;
|
icon: typeof Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTIONS: AdminSection[] = [
|
interface AdminGroup {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
sections: AdminSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: AdminGroup[] = [
|
||||||
{
|
{
|
||||||
href: 'users',
|
title: 'Access',
|
||||||
label: 'Users',
|
description: 'Who can sign in and what they can do once they do.',
|
||||||
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
|
sections: [
|
||||||
icon: Users,
|
{
|
||||||
|
href: 'users',
|
||||||
|
label: 'Users',
|
||||||
|
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'invitations',
|
||||||
|
label: 'Invitations',
|
||||||
|
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'roles',
|
||||||
|
label: 'Roles & Permissions',
|
||||||
|
description: 'Default permission sets and per-port role overrides.',
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'invitations',
|
title: 'Configuration',
|
||||||
label: 'Invitations',
|
description: 'Branding, integrations, and per-port settings.',
|
||||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
sections: [
|
||||||
icon: Mail,
|
{
|
||||||
|
href: 'email',
|
||||||
|
label: 'Email Settings',
|
||||||
|
description: 'From address, signatures, and per-port SMTP overrides.',
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'documenso',
|
||||||
|
label: 'Documenso & EOI',
|
||||||
|
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'reminders',
|
||||||
|
label: 'Reminders',
|
||||||
|
description: 'Default reminder behaviour and the daily-digest delivery window.',
|
||||||
|
icon: Bell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'branding',
|
||||||
|
label: 'Branding',
|
||||||
|
description: 'App name, logo, primary color, and email header/footer HTML.',
|
||||||
|
icon: Palette,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'settings',
|
||||||
|
label: 'System Settings',
|
||||||
|
description: 'Generic key/value configuration store for advanced flags.',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'webhooks',
|
||||||
|
label: 'Webhooks',
|
||||||
|
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
|
||||||
|
icon: Webhook,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'roles',
|
title: 'Content',
|
||||||
label: 'Roles & Permissions',
|
description: 'Forms, templates, and labels that users see.',
|
||||||
description: 'Default permission sets and per-port role overrides.',
|
sections: [
|
||||||
icon: Shield,
|
{
|
||||||
|
href: 'forms',
|
||||||
|
label: 'Forms',
|
||||||
|
description: 'Form templates used by client-facing inquiry and intake flows.',
|
||||||
|
icon: Sliders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'templates',
|
||||||
|
label: 'Document Templates',
|
||||||
|
description: 'PDF + email templates with merge-field placeholders.',
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
|
||||||
|
icon: Tag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'custom-fields',
|
||||||
|
label: 'Custom Fields',
|
||||||
|
description: 'Tenant-defined fields for clients, yachts, and reservations.',
|
||||||
|
icon: Key,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'audit',
|
title: 'Data Quality',
|
||||||
label: 'Audit Log',
|
description: 'Cleanup, imports, and the audit trail.',
|
||||||
description: 'Searchable log of every authenticated mutation in the system.',
|
sections: [
|
||||||
icon: ScrollText,
|
{
|
||||||
|
href: 'duplicates',
|
||||||
|
label: 'Duplicates',
|
||||||
|
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
|
||||||
|
icon: UsersRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'import',
|
||||||
|
label: 'Bulk Import',
|
||||||
|
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
||||||
|
icon: Upload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'audit',
|
||||||
|
label: 'Audit Log',
|
||||||
|
description: 'Searchable log of every authenticated mutation in the system.',
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'email',
|
title: 'Operations',
|
||||||
label: 'Email Settings',
|
description: 'Health checks and disaster recovery.',
|
||||||
description: 'From address, signatures, and per-port SMTP overrides.',
|
sections: [
|
||||||
icon: Mail,
|
{
|
||||||
|
href: 'reports',
|
||||||
|
label: 'Reports',
|
||||||
|
description: 'Saved analytics views and ad-hoc query results.',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'monitoring',
|
||||||
|
label: 'Queue Monitoring',
|
||||||
|
description: 'BullMQ queue health, throughput, and retry diagnostics.',
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'backup',
|
||||||
|
label: 'Backup & Restore',
|
||||||
|
description: 'Database snapshots and on-demand exports.',
|
||||||
|
icon: HardDrive,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'documenso',
|
title: 'Tenancy',
|
||||||
label: 'Documenso & EOI',
|
description: 'Multi-port and multi-install scaffolding.',
|
||||||
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
sections: [
|
||||||
icon: FileText,
|
{
|
||||||
|
href: 'ports',
|
||||||
|
label: 'Ports',
|
||||||
|
description: 'Manage the marinas/ports this installation serves.',
|
||||||
|
icon: Briefcase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'onboarding',
|
||||||
|
label: 'Onboarding',
|
||||||
|
description: 'Initial-setup wizard for fresh ports.',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'reminders',
|
title: 'Integrations',
|
||||||
label: 'Reminders',
|
description: 'Third-party providers wired into the app.',
|
||||||
description: 'Default reminder behaviour and the daily-digest delivery window.',
|
sections: [
|
||||||
icon: Bell,
|
{
|
||||||
},
|
href: 'ocr',
|
||||||
{
|
label: 'Receipt OCR',
|
||||||
href: 'branding',
|
description: 'Configure the AI provider used by the mobile receipt scanner.',
|
||||||
label: 'Branding',
|
icon: ScrollText,
|
||||||
description: 'App name, logo, primary color, and email header/footer HTML.',
|
},
|
||||||
icon: Palette,
|
{
|
||||||
},
|
href: 'website-analytics',
|
||||||
{
|
label: 'Website analytics (Umami)',
|
||||||
href: 'settings',
|
description: 'Per-port Umami URL, API token, and Website ID.',
|
||||||
label: 'System Settings',
|
icon: Globe,
|
||||||
description: 'Generic key/value configuration store for advanced flags.',
|
},
|
||||||
icon: Settings,
|
],
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'webhooks',
|
|
||||||
label: 'Webhooks',
|
|
||||||
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
|
|
||||||
icon: Webhook,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'forms',
|
|
||||||
label: 'Forms',
|
|
||||||
description: 'Form templates used by client-facing inquiry and intake flows.',
|
|
||||||
icon: Sliders,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'templates',
|
|
||||||
label: 'Document Templates',
|
|
||||||
description: 'PDF + email templates with merge-field placeholders.',
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'tags',
|
|
||||||
label: 'Tags',
|
|
||||||
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
|
|
||||||
icon: Tag,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'custom-fields',
|
|
||||||
label: 'Custom Fields',
|
|
||||||
description: 'Tenant-defined fields for clients, yachts, and reservations.',
|
|
||||||
icon: Key,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'reports',
|
|
||||||
label: 'Reports',
|
|
||||||
description: 'Saved analytics views and ad-hoc query results.',
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'monitoring',
|
|
||||||
label: 'Queue Monitoring',
|
|
||||||
description: 'BullMQ queue health, throughput, and retry diagnostics.',
|
|
||||||
icon: Database,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'import',
|
|
||||||
label: 'Bulk Import',
|
|
||||||
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
|
||||||
icon: Upload,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'backup',
|
|
||||||
label: 'Backup & Restore',
|
|
||||||
description: 'Database snapshots and on-demand exports.',
|
|
||||||
icon: HardDrive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'ports',
|
|
||||||
label: 'Ports',
|
|
||||||
description: 'Manage the marinas/ports this installation serves.',
|
|
||||||
icon: Briefcase,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'onboarding',
|
|
||||||
label: 'Onboarding',
|
|
||||||
description: 'Initial-setup wizard for fresh ports.',
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'ocr',
|
|
||||||
label: 'Receipt OCR',
|
|
||||||
description: 'Configure the AI provider used by the mobile receipt scanner.',
|
|
||||||
icon: ScrollText,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -165,36 +227,46 @@ export default async function AdminLandingPage({
|
|||||||
}) {
|
}) {
|
||||||
const { portSlug } = await params;
|
const { portSlug } = await params;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
{GROUPS.map((group) => (
|
||||||
{SECTIONS.map((s) => {
|
<section key={group.title} className="space-y-3">
|
||||||
const Icon = s.icon;
|
<div>
|
||||||
return (
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<Link
|
{group.title}
|
||||||
key={s.href}
|
</h2>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
<p className="text-xs text-muted-foreground/80">{group.description}</p>
|
||||||
href={`/${portSlug}/admin/${s.href}` as any}
|
</div>
|
||||||
className="block group"
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
>
|
{group.sections.map((s) => {
|
||||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
const Icon = s.icon;
|
||||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
return (
|
||||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
<Link
|
||||||
<div className="flex-1">
|
key={s.href}
|
||||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
</div>
|
href={`/${portSlug}/admin/${s.href}` as any}
|
||||||
</CardHeader>
|
className="block group"
|
||||||
<CardContent>
|
>
|
||||||
<CardDescription>{s.description}</CardDescription>
|
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||||
</Card>
|
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||||
</Link>
|
<div className="flex-1">
|
||||||
);
|
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||||
})}
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription>{s.description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Route-level loading UI for the client detail page. Renders while the
|
||||||
* server component resolves the session and the client component bootstraps
|
* server component resolves the session and the client component bootstraps
|
||||||
* its initial query — replaces the previous empty-header flash on direct
|
* its initial query - replaces the previous empty-header flash on direct
|
||||||
* URL visits.
|
* URL visits.
|
||||||
*/
|
*/
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header strip — title, badges, action buttons */}
|
{/* Header strip - title, badges, action buttons */}
|
||||||
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-7 w-56" />
|
<Skeleton className="h-7 w-56" />
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
|
|||||||
}, [setChrome]);
|
}, [setChrome]);
|
||||||
|
|
||||||
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
||||||
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
|
// fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
|
||||||
// the review step. Doubles as the source of truth for the billing entity prefill.
|
// the review step. Doubles as the source of truth for the billing entity prefill.
|
||||||
const { data: prefilledInterest } = useQuery<{
|
const { data: prefilledInterest } = useQuery<{
|
||||||
data: {
|
data: {
|
||||||
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Header — desktop only; mobile gets the title from the topbar */}
|
{/* Header - desktop only; mobile gets the title from the topbar */}
|
||||||
<div className="hidden sm:flex items-center gap-3">
|
<div className="hidden sm:flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
|
|||||||
{prefilledInterest?.data
|
{prefilledInterest?.data
|
||||||
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
||||||
prefilledInterest.data.berthMooringNumber
|
prefilledInterest.data.berthMooringNumber
|
||||||
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
|
? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
|
||||||
: ''
|
: ''
|
||||||
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
||||||
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
||||||
|
|||||||
@@ -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>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<RealtimeToasts />
|
<RealtimeToasts />
|
||||||
{/* Desktop shell — hidden by CSS on mobile */}
|
{/* Desktop shell - hidden by CSS on mobile */}
|
||||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
portRoles={portRoles}
|
portRoles={portRoles}
|
||||||
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
}}
|
}}
|
||||||
|
ports={ports}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile shell — hidden by CSS on desktop */}
|
{/* Mobile shell - hidden by CSS on desktop */}
|
||||||
<MobileLayout>{children}</MobileLayout>
|
<MobileLayout>{children}</MobileLayout>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
|
|||||||
@@ -12,14 +12,10 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function PortalLayout({
|
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
// This layout wraps all portal routes including login/verify
|
// This layout wraps all portal routes including login/verify
|
||||||
// We can't easily check pathname in a server layout, so we attempt
|
// We can't easily check pathname in a server layout, so we attempt
|
||||||
// to get the session and pass it down — login/verify pages handle their own
|
// to get the session and pass it down - login/verify pages handle their own
|
||||||
// redirect logic independently.
|
// redirect logic independently.
|
||||||
const session = await getPortalSession().catch(() => null);
|
const session = await getPortalSession().catch(() => null);
|
||||||
|
|
||||||
@@ -42,17 +38,11 @@ export default async function PortalLayout({
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{session && (
|
{session && (
|
||||||
<>
|
<>
|
||||||
<PortalHeader
|
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
|
||||||
portName={portName}
|
|
||||||
portLogoUrl={portLogoUrl}
|
|
||||||
clientName={clientName}
|
|
||||||
/>
|
|
||||||
<PortalNav />
|
<PortalNav />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
|
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
|
|||||||
<PasswordSetForm
|
<PasswordSetForm
|
||||||
endpoint="/api/portal/auth/activate"
|
endpoint="/api/portal/auth/activate"
|
||||||
title="Activate your account"
|
title="Activate your account"
|
||||||
description="Welcome — choose a password to finish setting up your client portal account."
|
description="Welcome - choose a password to finish setting up your client portal account."
|
||||||
successTitle="Account activated"
|
successTitle="Account activated"
|
||||||
successDescription="You can now sign in with your new password."
|
successDescription="You can now sign in with your new password."
|
||||||
submitLabel="Activate account"
|
submitLabel="Activate account"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Always returns 200 — caller never sees whether email exists.
|
// Always returns 200 - caller never sees whether email exists.
|
||||||
await fetch('/api/portal/auth/forgot-password', {
|
await fetch('/api/portal/auth/forgot-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
|
|||||||
<span className="font-medium text-gray-900">General Interest</span>
|
<span className="font-medium text-gray-900">General Interest</span>
|
||||||
)}
|
)}
|
||||||
{interest.berthArea && (
|
{interest.berthArea && (
|
||||||
<span className="text-sm text-gray-400">— {interest.berthArea}</span>
|
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{interest.leadCategory && (
|
{interest.leadCategory && (
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||||
{r.berthMooringNumber && (
|
{r.berthMooringNumber && (
|
||||||
<span className="text-sm text-gray-400">— Berth {r.berthMooringNumber}</span>
|
<span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -1,20 +1,51 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||||
import { QueryProvider } from '@/providers/query-provider';
|
import { QueryProvider } from '@/providers/query-provider';
|
||||||
import { PortProvider } from '@/providers/port-provider';
|
import { PortProvider } from '@/providers/port-provider';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
|
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
|
||||||
* topbar — the scanner is its own contained surface. Adds the PWA
|
* topbar - the scanner is its own contained surface. PWA manifest +
|
||||||
* manifest link + theme color so iOS/Android pick up "Add to Home
|
* iOS web-app meta tags are emitted via Next.js's metadata/viewport
|
||||||
* Screen". Auth check matches the dashboard layout so unauthorized
|
* exports so React doesn't try to render a second `<head>` mid-tree
|
||||||
* users still bounce to /login.
|
* (which throws hydration errors in the App Router). Auth check
|
||||||
|
* matches the dashboard layout so unauthorized users still bounce.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
return {
|
||||||
|
manifest: `/${portSlug}/scan/manifest.webmanifest`,
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: 'PN Scanner',
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
// Android/Chrome equivalent of the apple-* meta. metadata.appleWebApp
|
||||||
|
// covers iOS only; this preserves the existing PWA hint for Chrome.
|
||||||
|
'mobile-web-app-capable': 'yes',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: '#3a7bc8',
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function ScannerLayout({
|
export default async function ScannerLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
|
<PortProvider ports={[port]} defaultPortId={port.id}>
|
||||||
<head>
|
|
||||||
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
|
|
||||||
<meta name="theme-color" content="#3a7bc8" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
</head>
|
|
||||||
<div className="min-h-[100dvh] bg-background">{children}</div>
|
<div className="min-h-[100dvh] bg-background">{children}</div>
|
||||||
</PortProvider>
|
</PortProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
|
|||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
name: `${portName} — Scanner`,
|
name: `${portName} - Scanner`,
|
||||||
short_name: 'Scanner',
|
short_name: 'Scanner',
|
||||||
description: `Capture and submit expense receipts for ${portName}.`,
|
description: `Capture and submit expense receipts for ${portName}.`,
|
||||||
start_url: `/${portSlug}/scan`,
|
start_url: `/${portSlug}/scan`,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
|||||||
import { ScanShell } from '@/components/scan/scan-shell';
|
import { ScanShell } from '@/components/scan/scan-shell';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Scan receipt — Port Nimara',
|
title: 'Scan receipt - Port Nimara',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScanPage() {
|
export default function ScanPage() {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liveness probe — confirms the Next.js process is responding.
|
* Liveness probe - confirms the Next.js process is responding.
|
||||||
*
|
*
|
||||||
* Returns 200 unconditionally; if the process is wedged or has crashed
|
* Returns 200 unconditionally; if the process is wedged or has crashed
|
||||||
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
||||||
* checks in this endpoint — a transient downstream blip should drop the
|
* checks in this endpoint - a transient downstream blip should drop the
|
||||||
* pod from the load balancer (readiness), not restart the pod (liveness).
|
* pod from the load balancer (readiness), not restart the pod (liveness).
|
||||||
*
|
*
|
||||||
* For deep dependency checks, hit `/api/ready` instead.
|
* For deep dependency checks, hit `/api/ready` instead.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
|||||||
// Keep the helper aligned with that.
|
// Keep the helper aligned with that.
|
||||||
type Tx = typeof db;
|
type Tx = typeof db;
|
||||||
|
|
||||||
// POST /api/public/interests — unauthenticated public interest registration.
|
// POST /api/public/interests - unauthenticated public interest registration.
|
||||||
// Creates the trio (client + yacht + interest) plus an optional company +
|
// Creates the trio (client + yacht + interest) plus an optional company +
|
||||||
// membership, all inside a single transaction.
|
// membership, all inside a single transaction.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||||
|
|
||||||
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
// Resolve berth by mooring number (if provided). Read-only lookup - safe
|
||||||
// to do outside the transaction.
|
// to do outside the transaction.
|
||||||
let berthId: string | null = null;
|
let berthId: string | null = null;
|
||||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/public/residential-inquiries — unauthenticated entry point for
|
* POST /api/public/residential-inquiries - unauthenticated entry point for
|
||||||
* the public website's residential interest form. Creates a
|
* the public website's residential interest form. Creates a
|
||||||
* `residential_clients` row and an opening `residential_interests` row in a
|
* `residential_clients` row and an opening `residential_interests` row in a
|
||||||
* single transaction.
|
* single transaction.
|
||||||
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
|
|||||||
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
||||||
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
||||||
|
|
||||||
// Send notification emails (non-blocking — failures shouldn't 500 the
|
// Send notification emails (non-blocking - failures shouldn't 500 the
|
||||||
// public form).
|
// public form).
|
||||||
void sendResidentialNotifications({
|
void sendResidentialNotifications({
|
||||||
portId,
|
portId,
|
||||||
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
|
|||||||
});
|
});
|
||||||
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
await sendEmail(data.email, confirmation.subject, confirmation.html);
|
||||||
|
|
||||||
// Sales-team alert — pull recipients from system_settings if configured;
|
// Sales-team alert - pull recipients from system_settings if configured;
|
||||||
// fall back to the inquiry_contact_email if available.
|
// fall back to the inquiry_contact_email if available.
|
||||||
const recipientsRow = await db.query.systemSettings.findFirst({
|
const recipientsRow = await db.query.systemSettings.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
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
|
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||||
* load balancer until the next probe succeeds; it should not trigger a
|
* load balancer until the next probe succeeds; it should not trigger a
|
||||||
* pod restart (that's what `/api/health` is for).
|
* pod restart (that's what `/api/health` is for).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
|||||||
* exercised by the realapi socket fanout test.
|
* exercised by the realapi socket fanout test.
|
||||||
*
|
*
|
||||||
* Requires super_admin or per-port admin permissions; the engine itself
|
* Requires super_admin or per-port admin permissions; the engine itself
|
||||||
* is idempotent — duplicate runs only re-evaluate, never duplicate rows.
|
* is idempotent - duplicate runs only re-evaluate, never duplicate rows.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(async (_req, ctx) => {
|
export const POST = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
|
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin probe — calls Documenso /api/v1/health using the port's effective
|
* Admin probe - calls Documenso /api/v1/health using the port's effective
|
||||||
* config. Used by the "Test connection" button on /admin/documenso.
|
* config. Used by the "Test connection" button on /admin/documenso.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
|
|||||||
.map((p) => {
|
.map((p) => {
|
||||||
const a = clientById.get(p.clientAId);
|
const a = clientById.get(p.clientAId);
|
||||||
const b = clientById.get(p.clientBId);
|
const b = clientById.get(p.clientBId);
|
||||||
if (!a || !b) return null; // FK orphan — shouldn't happen, but be defensive
|
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
|
||||||
// Skip pairs where one side has already been merged or archived.
|
// Skip pairs where one side has already been merged or archived.
|
||||||
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
|
|||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
// crm_user_invites is a global table (no per-port column) — invites
|
// crm_user_invites is a global table (no per-port column) - invites
|
||||||
// mint better-auth users that may later be assigned roles in any
|
// mint better-auth users that may later be assigned roles in any
|
||||||
// port. Listing it cross-tenant would let a port-A director
|
// port. Listing it cross-tenant would let a port-A director
|
||||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const schema = z.object({
|
|||||||
apiKey: z.string().min(1),
|
apiKey: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// `manage_settings`-gated for parity with the parent OCR settings route —
|
// `manage_settings`-gated for parity with the parent OCR settings route -
|
||||||
// triggers outbound AI provider auth requests using a caller-supplied key.
|
// triggers outbound AI provider auth requests using a caller-supplied key.
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req) => {
|
withPermission('admin', 'manage_settings', async (req) => {
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
|
|||||||
* POST /api/v1/admin/templates/preview
|
* POST /api/v1/admin/templates/preview
|
||||||
*
|
*
|
||||||
* Generates a preview PDF from a TipTap JSON content block.
|
* Generates a preview PDF from a TipTap JSON content block.
|
||||||
* Returns { data: { pdfBase64: string } } — the client can render this
|
* Returns { data: { pdfBase64: string } } - the client can render this
|
||||||
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
|
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
|
||||||
*
|
*
|
||||||
* Body:
|
* Body:
|
||||||
* content: TipTap JSON document
|
* content: TipTap JSON document
|
||||||
* sampleData?: Record<string, string> — variable substitutions
|
* sampleData?: Record<string, string> - variable substitutions
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('documents', 'manage', async (req, _ctx) => {
|
withPermission('documents', 'manage', async (req, _ctx) => {
|
||||||
@@ -60,10 +60,7 @@ export const POST = withAuth(
|
|||||||
/**
|
/**
|
||||||
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
|
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
|
||||||
*/
|
*/
|
||||||
function substituteInDoc(
|
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
|
||||||
node: TipTapNode,
|
|
||||||
data: Record<string, string>,
|
|
||||||
): TipTapNode {
|
|
||||||
if (node.type === 'text' && node.text) {
|
if (node.type === 'text' && node.text) {
|
||||||
return { ...node, text: substituteVariables(node.text, data) };
|
return { ...node, text: substituteVariables(node.text, data) };
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
getRevenueBreakdown,
|
||||||
type DateRange,
|
type DateRange,
|
||||||
type MetricBase,
|
type MetricBase,
|
||||||
|
type PresetDateRange,
|
||||||
} from '@/lib/services/analytics.service';
|
} from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||||
@@ -18,17 +19,69 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
|
|||||||
lead_source_attribution: getLeadSourceAttribution,
|
lead_source_attribution: getLeadSourceAttribution,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
const rawRange = url.searchParams.get('range') ?? '30d';
|
||||||
|
const fromParam = url.searchParams.get('from');
|
||||||
|
const toParam = url.searchParams.get('to');
|
||||||
|
|
||||||
if (!metric || !(metric in METRICS)) {
|
if (!metric || !(metric in METRICS)) {
|
||||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!ALL_RANGES.includes(range)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
let range: DateRange;
|
||||||
|
if (rawRange === 'custom') {
|
||||||
|
if (!fromParam || !toParam) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fromParam > toParam) {
|
||||||
|
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
|
||||||
|
// (rolls over silently when handed to `new Date`). Re-serialize and
|
||||||
|
// confirm it matches the input to catch invalid calendar values.
|
||||||
|
for (const [label, raw] of [
|
||||||
|
['from', fromParam],
|
||||||
|
['to', toParam],
|
||||||
|
] as const) {
|
||||||
|
const d = new Date(`${raw}T00:00:00.000Z`);
|
||||||
|
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `\`${label}\` is not a valid calendar date` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Backstop against the occupancy-timeline N+1 query loop. Each day
|
||||||
|
// in the range issues its own DB query, so a multi-year custom
|
||||||
|
// range would saturate the connection pool. 365 days is a generous
|
||||||
|
// ceiling for analytical queries; if a longer span is needed, the
|
||||||
|
// service should be restructured to use `generate_series` instead
|
||||||
|
// of a JS loop.
|
||||||
|
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
|
||||||
|
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
|
||||||
|
if ((toMs - fromMs) / 86_400_000 > 365) {
|
||||||
|
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
|
||||||
|
}
|
||||||
|
range = { kind: 'custom', from: fromParam, to: toParam };
|
||||||
|
} else {
|
||||||
|
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||||
|
}
|
||||||
|
range = rawRange as PresetDateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await METRICS[metric](ctx.portId, range);
|
const data = await METRICS[metric](ctx.portId, range);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||||
|
|
||||||
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||||
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
// PATCH cannot use `withPermission` wrapper - the required permission depends
|
||||||
// on the `action` field in the body. `requirePermission` is called inside the
|
// on the `action` field in the body. `requirePermission` is called inside the
|
||||||
// handler after the body is parsed.
|
// handler after the body is parsed.
|
||||||
export const PATCH = withAuth(patchHandler);
|
export const PATCH = withAuth(patchHandler);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const PUT = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// PATCH /api/v1/berths/[id]/waiting-list — reorder a single entry
|
// PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
|
||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
|
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { getBerthOptions } from '@/lib/services/berths.service';
|
import { getBerthOptions } from '@/lib/services/berths.service';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
|
// GET /api/v1/berths/options - lightweight list for selects/comboboxes
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('berths', 'view', async (req, ctx) => {
|
withPermission('berths', 'view', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const inviteSchema = z.object({
|
|||||||
*
|
*
|
||||||
* Admin creates a portal account for a client and triggers the activation
|
* Admin creates a portal account for a client and triggers the activation
|
||||||
* email. Idempotent in spirit: if a portal user already exists for the
|
* email. Idempotent in spirit: if a portal user already exists for the
|
||||||
* email, returns 409 — the admin can resend the activation via
|
* email, returns 409 - the admin can resend the activation via
|
||||||
* ?action=resend.
|
* ?action=resend.
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
|
|||||||
const nameResult = rawName ? normalizeName(rawName) : null;
|
const nameResult = rawName ? normalizeName(rawName) : null;
|
||||||
|
|
||||||
// If the caller didn't give us anything useful to match on, return empty
|
// If the caller didn't give us anything useful to match on, return empty
|
||||||
// — short-circuit rather than scan every client for nothing.
|
// - short-circuit rather than scan every client for nothing.
|
||||||
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
||||||
return NextResponse.json({ data: [] });
|
return NextResponse.json({ data: [] });
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
|
|||||||
mediumScore: 50,
|
mediumScore: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only return medium+ — low-confidence noise isn't useful at the
|
// Only return medium+ - low-confidence noise isn't useful at the
|
||||||
// create-form layer (background scoring queue picks those up).
|
// create-form layer (background scoring queue picks those up).
|
||||||
const useful = matches.filter((m) => m.confidence !== 'low');
|
const useful = matches.filter((m) => m.confidence !== 'low');
|
||||||
if (useful.length === 0) {
|
if (useful.length === 0) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
|
|||||||
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||||
|
|
||||||
const mergeSchema = z.object({
|
const mergeSchema = z.object({
|
||||||
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
|
/** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
|
||||||
targetId: z.string().min(1),
|
targetId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const POST = withAuth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-port budget gate — refuse the call before we spend tokens
|
// Per-port budget gate - refuse the call before we spend tokens
|
||||||
// when the port has already hit its hard cap, or when the request
|
// when the port has already hit its hard cap, or when the request
|
||||||
// would push it past the cap. Soft-cap warnings ride along on the
|
// would push it past the cap. Soft-cap warnings ride along on the
|
||||||
// success response so the UI can show a banner without blocking.
|
// success response so the UI can show a banner without blocking.
|
||||||
@@ -99,7 +99,7 @@ export const POST = withAuth(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
||||||
// Provider hiccup — degrade to manual entry rather than 500-ing.
|
// Provider hiccup - degrade to manual entry rather than 500-ing.
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
parsed: EMPTY,
|
parsed: EMPTY,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const POST = withAuth(
|
|||||||
try {
|
try {
|
||||||
const body = await parseBody(req, createFolderSchema);
|
const body = await parseBody(req, createFolderSchema);
|
||||||
|
|
||||||
// Sanitize path — no null bytes, no path traversal
|
// Sanitize path - no null bytes, no path traversal
|
||||||
const safePath = body.path
|
const safePath = body.path
|
||||||
.replace(/\x00/g, '')
|
.replace(/\x00/g, '')
|
||||||
.replace(/\.\.\//g, '')
|
.replace(/\.\.\//g, '')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/v1/interests/[id]/recommendations — add manual recommendation
|
// POST /api/v1/interests/[id]/recommendations - add manual recommendation
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('interests', 'edit', async (req, ctx, params) => {
|
withPermission('interests', 'edit', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
|
|||||||
|
|
||||||
const OUTCOME_LABELS: Record<string, string> = {
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
won: 'Won',
|
won: 'Won',
|
||||||
lost_other_marina: 'Lost — went to another marina',
|
lost_other_marina: 'Lost - went to another marina',
|
||||||
lost_unqualified: 'Lost — unqualified',
|
lost_unqualified: 'Lost - unqualified',
|
||||||
lost_no_response: 'Lost — no response',
|
lost_no_response: 'Lost - no response',
|
||||||
cancelled: 'Cancelled',
|
cancelled: 'Cancelled',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ function buildAuditDescription(
|
|||||||
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
|
||||||
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
|
||||||
const reason = (newValue?.reason as string | undefined) ?? '';
|
const reason = (newValue?.reason as string | undefined) ?? '';
|
||||||
return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`;
|
return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'outcome_cleared') {
|
if (type === 'outcome_cleared') {
|
||||||
@@ -200,9 +200,9 @@ function buildAuditDescription(
|
|||||||
const reason = (newValue.reason as string | undefined) ?? '';
|
const reason = (newValue.reason as string | undefined) ?? '';
|
||||||
const auto = userId === 'system';
|
const auto = userId === 'system';
|
||||||
if (auto) {
|
if (auto) {
|
||||||
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
|
return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
|
||||||
}
|
}
|
||||||
return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`;
|
return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'update' && newValue?.pipelineStage) {
|
if (action === 'update' && newValue?.pipelineStage) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
|
|||||||
|
|
||||||
const results = await search(ctx.portId, q);
|
const results = await search(ctx.portId, q);
|
||||||
|
|
||||||
// Fire-and-forget — do not await
|
// Fire-and-forget - do not await
|
||||||
saveRecentSearch(ctx.userId, ctx.portId, q);
|
saveRecentSearch(ctx.userId, ctx.portId, q);
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
|
|||||||
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) {
|
if (existing) {
|
||||||
logger.info({ signatureHash }, 'Duplicate Documenso webhook — skipping');
|
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
|
||||||
return NextResponse.json({ ok: true }, { status: 200 });
|
return NextResponse.json({ ok: true }, { status: 200 });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
@apply bg-background text-foreground font-sans antialiased;
|
@apply bg-background text-foreground font-sans antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wave watermark — subtle background texture for auth pages */
|
/* Wave watermark - subtle background texture for auth pages */
|
||||||
.wave-watermark {
|
.wave-watermark {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
||||||
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
||||||
*
|
*
|
||||||
* IMPORTANT: only `display: none` rules are emitted — we never set a positive
|
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
|
||||||
* display, because the desktop shell uses Tailwind's `flex` class which would
|
* display, because the desktop shell uses Tailwind's `flex` class which would
|
||||||
* be overridden by `display: block` (same specificity, later cascade).
|
* be overridden by `display: block` (same specificity, later cascade).
|
||||||
*/
|
*/
|
||||||
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recharts focus-ring suppression.
|
||||||
|
*
|
||||||
|
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
|
||||||
|
* them (the library adds tabindex on chart sectors / paths). The global
|
||||||
|
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
|
||||||
|
* which on a chart surface reads as a stray rectangle around the plot
|
||||||
|
* area. Hover/tooltip already handles chart interactivity, so suppress
|
||||||
|
* the ring entirely here.
|
||||||
|
*
|
||||||
|
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
|
||||||
|
* it during purge (an earlier copy inside `@layer base` was being
|
||||||
|
* silently removed at build time, leaving the ring intact).
|
||||||
|
*/
|
||||||
|
div.recharts-wrapper:focus,
|
||||||
|
div.recharts-wrapper:focus-visible,
|
||||||
|
svg.recharts-surface:focus,
|
||||||
|
svg.recharts-surface:focus-visible,
|
||||||
|
div.recharts-responsive-container:focus,
|
||||||
|
div.recharts-responsive-container:focus-visible,
|
||||||
|
.recharts-wrapper *:focus,
|
||||||
|
.recharts-wrapper *:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
--tw-ring-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||||||
|
--tw-ring-color: transparent !important;
|
||||||
|
--tw-ring-offset-color: transparent !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function AuditLogList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
// Filter state — debounce text inputs.
|
// Filter state - debounce text inputs.
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [entityType, setEntityType] = useState<string>('all');
|
const [entityType, setEntityType] = useState<string>('all');
|
||||||
const [action, setAction] = useState<string>('all');
|
const [action, setAction] = useState<string>('all');
|
||||||
@@ -215,7 +215,7 @@ export function AuditLogList() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-xs text-muted-foreground">—</span>;
|
return <span className="text-xs text-muted-foreground">-</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -245,7 +245,7 @@ export function AuditLogList() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Audit Log"
|
title="Audit Log"
|
||||||
eyebrow="Admin"
|
eyebrow="Admin"
|
||||||
description="Every state change in this port — fully searchable."
|
description="Every state change in this port - fully searchable."
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CustomFieldForm({
|
export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
field,
|
|
||||||
onSuccess,
|
|
||||||
}: CustomFieldFormProps) {
|
|
||||||
const isEdit = !!field;
|
const isEdit = !!field;
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
@@ -72,9 +67,7 @@ export function CustomFieldForm({
|
|||||||
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
||||||
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
||||||
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
||||||
const [selectOptions, setSelectOptions] = useState<string[]>(
|
const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
|
||||||
field?.selectOptions ?? [],
|
|
||||||
);
|
|
||||||
const [newOption, setNewOption] = useState('');
|
const [newOption, setNewOption] = useState('');
|
||||||
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
||||||
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
||||||
@@ -169,13 +162,11 @@ export function CustomFieldForm({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
|
||||||
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
||||||
{/* Entity Type — create only */}
|
{/* Entity Type - create only */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
@@ -198,7 +189,7 @@ export function CustomFieldForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Field Name — create only */}
|
{/* Field Name - create only */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cf-field-name">
|
<Label htmlFor="cf-field-name">
|
||||||
Field Name
|
Field Name
|
||||||
@@ -232,7 +223,7 @@ export function CustomFieldForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Field Type — create only */}
|
{/* Field Type - create only */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="cf-field-type">Field Type</Label>
|
<Label htmlFor="cf-field-type">Field Type</Label>
|
||||||
{isEdit ? (
|
{isEdit ? (
|
||||||
@@ -260,7 +251,7 @@ export function CustomFieldForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select Options — visible when fieldType = 'select' */}
|
{/* Select Options - visible when fieldType = 'select' */}
|
||||||
{fieldType === 'select' && (
|
{fieldType === 'select' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Options</Label>
|
<Label>Options</Label>
|
||||||
@@ -302,11 +293,7 @@ export function CustomFieldForm({
|
|||||||
{/* Is Required */}
|
{/* Is Required */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="cf-is-required">Required field</Label>
|
<Label htmlFor="cf-is-required">Required field</Label>
|
||||||
<Switch
|
<Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
|
||||||
id="cf-is-required"
|
|
||||||
checked={isRequired}
|
|
||||||
onCheckedChange={setIsRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Order */}
|
{/* Sort Order */}
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||||
|
|
||||||
@@ -61,20 +55,13 @@ interface TemplateFormProps {
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateForm({
|
export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
template,
|
|
||||||
onSuccess,
|
|
||||||
}: TemplateFormProps) {
|
|
||||||
const isEdit = !!template;
|
const isEdit = !!template;
|
||||||
|
|
||||||
const [name, setName] = useState(template?.name ?? '');
|
const [name, setName] = useState(template?.name ?? '');
|
||||||
const [type, setType] = useState(template?.templateType ?? 'other');
|
const [type, setType] = useState(template?.templateType ?? 'other');
|
||||||
const [contentJson, setContentJson] = useState(
|
const [contentJson, setContentJson] = useState(
|
||||||
template?.content
|
template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
|
||||||
? JSON.stringify(template.content, null, 2)
|
|
||||||
: EMPTY_DOC,
|
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -86,7 +73,7 @@ export function TemplateForm({
|
|||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
setJsonError('Invalid JSON — check syntax.');
|
setJsonError('Invalid JSON - check syntax.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,8 +102,7 @@ export function TemplateForm({
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||||
err instanceof Error ? err.message : 'Something went wrong';
|
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -127,9 +113,7 @@ export function TemplateForm({
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
|
||||||
{isEdit ? 'Edit Template' : 'New Document Template'}
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||||
@@ -145,7 +129,7 @@ export function TemplateForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type — only on create */}
|
{/* Type - only on create */}
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="template-type">Document Type</Label>
|
<Label htmlFor="template-type">Document Type</Label>
|
||||||
@@ -166,15 +150,11 @@ export function TemplateForm({
|
|||||||
|
|
||||||
{/* TipTap JSON Content */}
|
{/* TipTap JSON Content */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="template-content">
|
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
|
||||||
Document Content (TipTap JSON)
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Paste or edit TipTap JSON. Use{' '}
|
Paste or edit TipTap JSON. Use{' '}
|
||||||
<code className="rounded bg-muted px-1 text-xs">
|
<code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
|
||||||
{'{{variable.key}}'}
|
dynamic content.
|
||||||
</code>{' '}
|
|
||||||
tokens for dynamic content.
|
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
id="template-content"
|
id="template-content"
|
||||||
@@ -187,9 +167,7 @@ export function TemplateForm({
|
|||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
{jsonError && (
|
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
|
||||||
<p className="text-xs text-destructive">{jsonError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Available Variables Reference */}
|
{/* Available Variables Reference */}
|
||||||
@@ -200,19 +178,15 @@ export function TemplateForm({
|
|||||||
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||||
{TEMPLATE_VARIABLES.map((v) => (
|
{TEMPLATE_VARIABLES.map((v) => (
|
||||||
<div key={v.key} className="text-xs">
|
<div key={v.key} className="text-xs">
|
||||||
<code className="rounded bg-muted px-1">
|
<code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
|
||||||
{`{{${v.key}}}`}
|
<span className="text-muted-foreground">- {v.label}</span>
|
||||||
</code>{' '}
|
|
||||||
<span className="text-muted-foreground">— {v.label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
@@ -225,11 +199,7 @@ export function TemplateForm({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !!jsonError}>
|
<Button type="submit" disabled={loading || !!jsonError}>
|
||||||
{loading
|
{loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
|
||||||
? 'Saving…'
|
|
||||||
: isEdit
|
|
||||||
? 'Save Changes'
|
|
||||||
: 'Create Template'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
|||||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TemplateForm } from './template-form';
|
import { TemplateForm } from './template-form';
|
||||||
import { TemplateVersionHistory } from './template-version-history';
|
import { TemplateVersionHistory } from './template-version-history';
|
||||||
@@ -57,9 +52,7 @@ export function TemplateList() {
|
|||||||
const fetchTemplates = useCallback(async () => {
|
const fetchTemplates = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ data: AdminTemplate[] }>(
|
const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
|
||||||
'/api/v1/admin/templates',
|
|
||||||
);
|
|
||||||
setTemplates(res.data);
|
setTemplates(res.data);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -122,9 +115,7 @@ export function TemplateList() {
|
|||||||
accessorKey: 'version',
|
accessorKey: 'version',
|
||||||
header: 'Version',
|
header: 'Version',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">v{row.original.version}</span>
|
||||||
v{row.original.version}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -151,10 +142,7 @@ export function TemplateList() {
|
|||||||
header: '',
|
header: '',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<TemplatePreview
|
<TemplatePreview content={row.original.content} templateName={row.original.name} />
|
||||||
content={row.original.content}
|
|
||||||
templateName={row.original.name}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -177,9 +165,7 @@ export function TemplateList() {
|
|||||||
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
||||||
onClick={() => handleToggleActive(row.original)}
|
onClick={() => handleToggleActive(row.original)}
|
||||||
>
|
>
|
||||||
<span className="text-xs">
|
<span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
|
||||||
{row.original.isActive ? 'Off' : 'On'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
trigger={
|
trigger={
|
||||||
@@ -233,9 +219,7 @@ export function TemplateList() {
|
|||||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||||
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
|
||||||
Version History — {historyTemplate?.name}
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{historyTemplate && (
|
{historyTemplate && (
|
||||||
|
|||||||
@@ -3,12 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Eye, ExternalLink } from 'lucide-react';
|
import { Eye, ExternalLink } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||||
|
|
||||||
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Build sample data from TEMPLATE_VARIABLES examples
|
// Build sample data from TEMPLATE_VARIABLES examples
|
||||||
const sampleData = Object.fromEntries(
|
const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
|
||||||
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handlePreview() {
|
async function handlePreview() {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
|||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle>Preview — {templateName}</DialogTitle>
|
<DialogTitle>Preview - {templateName}</DialogTitle>
|
||||||
{pdfBase64 && (
|
{pdfBase64 && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleOpenInNewTab}
|
|
||||||
className="mr-6"
|
|
||||||
>
|
|
||||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Open in new tab
|
Open in new tab
|
||||||
</Button>
|
</Button>
|
||||||
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
|
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pdfBase64 && !loading && (
|
{pdfBase64 && !loading && (
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function InvitationsManager() {
|
|||||||
{invites.map((i) => (
|
{invites.map((i) => (
|
||||||
<tr key={i.id} className="border-t">
|
<tr key={i.id} className="border-t">
|
||||||
<td className="px-3 py-2 font-medium">{i.email}</td>
|
<td className="px-3 py-2 font-medium">{i.email}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '—'}</td>
|
<td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
|
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
|
||||||
</td>
|
</td>
|
||||||
@@ -163,7 +163,7 @@ export function InvitationsManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
|||||||
Enable AI receipt parsing for this port
|
Enable AI receipt parsing for this port
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Off by default. Receipts are read on-device using Tesseract.js — accurate enough for
|
Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
|
||||||
most receipts and incurs no AI cost. Turning this on lets the configured provider
|
most receipts and incurs no AI cost. Turning this on lets the configured provider
|
||||||
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
||||||
</p>
|
</p>
|
||||||
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
|
|||||||
id={`apiKey-${scope}`}
|
id={`apiKey-${scope}`}
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
|
placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setApiKey(e.target.value);
|
setApiKey(e.target.value);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
|
|||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(ts: number | undefined): string {
|
function formatDate(ts: number | undefined): string {
|
||||||
if (!ts) return '—';
|
if (!ts) return '-';
|
||||||
return new Date(ts).toLocaleString();
|
return new Date(ts).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function truncateReason(reason: string | undefined): string {
|
function truncateReason(reason: string | undefined): string {
|
||||||
if (!reason) return '—';
|
if (!reason) return '-';
|
||||||
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{total} total jobs — page {page} of {totalPages}
|
{total} total jobs - page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function RoleList() {
|
|||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
header: 'Description',
|
header: 'Description',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-sm">{row.original.description ?? '—'}</span>
|
<span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -241,7 +241,14 @@ export function SettingsManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
|
{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">
|
<div className="flex-1">
|
||||||
<Label>{setting.label}</Label>
|
<Label>{setting.label}</Label>
|
||||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||||
@@ -249,7 +256,7 @@ export function SettingsManager() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-64"
|
className="w-full sm:w-64"
|
||||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setValues((prev) => ({
|
setValues((prev) => ({
|
||||||
@@ -283,7 +290,10 @@ export function SettingsManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
|
{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">
|
<div className="flex-1">
|
||||||
<Label>{setting.label}</Label>
|
<Label>{setting.label}</Label>
|
||||||
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
<p className="text-xs text-muted-foreground">{setting.description}</p>
|
||||||
@@ -291,7 +301,7 @@ export function SettingsManager() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
className="w-24"
|
className="w-full sm:w-24"
|
||||||
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setValues((prev) => ({
|
setValues((prev) => ({
|
||||||
@@ -353,7 +363,7 @@ export function SettingsManager() {
|
|||||||
);
|
);
|
||||||
void saveSetting(setting.key, parsed);
|
void saveSetting(setting.key, parsed);
|
||||||
} catch {
|
} catch {
|
||||||
// invalid JSON — do nothing
|
// invalid JSON - do nothing
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
|
|||||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email subtitle — only when display name is shown as title */}
|
{/* Email subtitle - only when display name is shown as title */}
|
||||||
{user.displayName && user.displayName !== user.email ? (
|
{user.displayName && user.displayName !== user.email ? (
|
||||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load(page);
|
void load(page);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [webhookId, page]);
|
}, [webhookId, page]);
|
||||||
|
|
||||||
if (loading && deliveries.length === 0) {
|
if (loading && deliveries.length === 0) {
|
||||||
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
|
|||||||
<TableRow key={d.id}>
|
<TableRow key={d.id}>
|
||||||
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
|
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
|
||||||
{d.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">
|
|
||||||
{d.responseStatus ?? '—'}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
|
||||||
<TableCell className="text-sm">{d.attempt}</TableCell>
|
<TableCell className="text-sm">{d.attempt}</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{d.deliveredAt
|
{d.deliveredAt
|
||||||
|
|||||||
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() {
|
export function AlertBell() {
|
||||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Count is cheap (one aggregate query) — fire on every page so the badge stays live.
|
// Count is cheap (one aggregate query) - fire on every page so the badge stays live.
|
||||||
// List is heavier — only fetch when the popover is actually open.
|
// List is heavier - only fetch when the popover is actually open.
|
||||||
const { data: count } = useAlertCount();
|
const { data: count } = useAlertCount();
|
||||||
const { data: list, isLoading } = useAlertList('open', open);
|
const { data: list, isLoading } = useAlertList('open', open);
|
||||||
useAlertRealtime();
|
useAlertRealtime();
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ export function AlertRail() {
|
|||||||
<section
|
<section
|
||||||
data-testid="alert-rail"
|
data-testid="alert-rail"
|
||||||
aria-label="Active alerts"
|
aria-label="Active alerts"
|
||||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
// Natural height - the parent aside no longer forces 100% of the
|
||||||
// gives this rail a sibling column whose height it should match. On
|
// dashboard grid row, so the rail can sit compactly under Reminders
|
||||||
// mobile (single-column stack) there's no fixed-height context, so
|
// without bleeding down into the Recent Activity panel below.
|
||||||
// forcing 100% height makes the section overflow / look stretched.
|
className="flex flex-col gap-3"
|
||||||
className="flex flex-col gap-3 xl:h-full"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||||
@@ -57,7 +56,7 @@ export function AlertRail() {
|
|||||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||||
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
+{overflow} more — view all
|
+{overflow} more - view all
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'mooringNumber',
|
accessorKey: 'mooringNumber',
|
||||||
header: 'Mooring #',
|
header: 'Mooring #',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
|
||||||
<span className="font-medium">{row.original.mooringNumber}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'area',
|
accessorKey: 'area',
|
||||||
header: 'Area',
|
header: 'Area',
|
||||||
cell: ({ row }) => row.original.area ?? '—',
|
cell: ({ row }) => row.original.area ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { lengthM, widthM } = row.original;
|
const { lengthM, widthM } = row.original;
|
||||||
if (!lengthM && !widthM) return '—';
|
if (!lengthM && !widthM) return '-';
|
||||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
header: 'Price',
|
header: 'Price',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { price, priceCurrency } = row.original;
|
const { price, priceCurrency } = row.original;
|
||||||
if (!price) return '—';
|
if (!price) return '-';
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: priceCurrency || 'USD',
|
currency: priceCurrency || 'USD',
|
||||||
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: 'tenureType',
|
accessorKey: 'tenureType',
|
||||||
header: 'Tenure',
|
header: 'Tenure',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
|
||||||
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function SelectOrEmpty({
|
|||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={NONE}>—</SelectItem>
|
<SelectItem value={NONE}>-</SelectItem>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<SelectItem key={opt} value={opt}>
|
<SelectItem key={opt} value={opt}>
|
||||||
{opt}
|
{opt}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
href={`/${portSlug}/interests/${i.id}` as never}
|
href={`/${portSlug}/interests/${i.id}` as never}
|
||||||
className="hover:text-brand"
|
className="hover:text-brand"
|
||||||
>
|
>
|
||||||
{i.clientName ?? '—'}
|
{i.clientName ?? '-'}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'}
|
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'}
|
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
{new Date(i.createdAt).toLocaleDateString()}
|
{new Date(i.createdAt).toLocaleDateString()}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function BerthList() {
|
|||||||
title="Berths"
|
title="Berths"
|
||||||
description="View and manage berth allocations"
|
description="View and manage berth allocations"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
// No "New" button — berths are import-only
|
// No "New" button - berths are import-only
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
|
||||||
who's interested + how warm without clicking into the Interests tab. */}
|
who's interested + how warm without clicking into the Interests tab. */}
|
||||||
<BerthInterestPulse berthId={berth.id} />
|
<BerthInterestPulse berthId={berth.id} />
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function getClientColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
||||||
if (!primary) return <span className="text-muted-foreground">—</span>;
|
if (!primary) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
||||||
@@ -86,7 +86,7 @@ export function getClientColumns({
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const iso = getValue() as string | null;
|
const iso = getValue() as string | null;
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
|
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -96,7 +96,7 @@ export function getClientColumns({
|
|||||||
header: 'Source',
|
header: 'Source',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const source = getValue() as string | null;
|
const source = getValue() as string | null;
|
||||||
if (!source) return <span className="text-muted-foreground">—</span>;
|
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="capitalize text-xs">
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
{SOURCE_LABELS[source] ?? source}
|
{SOURCE_LABELS[source] ?? source}
|
||||||
@@ -111,7 +111,7 @@ export function getClientColumns({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const c = row.original.yachtCount ?? 0;
|
const c = row.original.yachtCount ?? 0;
|
||||||
return c === 0 ? (
|
return c === 0 ? (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{c}
|
{c}
|
||||||
@@ -126,7 +126,7 @@ export function getClientColumns({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const c = row.original.companyCount ?? 0;
|
const c = row.original.companyCount ?? 0;
|
||||||
return c === 0 ? (
|
return c === 0 ? (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{c}
|
{c}
|
||||||
@@ -140,7 +140,7 @@ export function getClientColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const clientTags = row.original.tags ?? [];
|
const clientTags = row.original.tags ?? [];
|
||||||
if (clientTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{clientTags.slice(0, 3).map((tag) => (
|
{clientTags.slice(0, 3).map((tag) => (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
|
|||||||
|
|
||||||
function formatSince(startDate: string | Date): string {
|
function formatSince(startDate: string | Date): string {
|
||||||
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
|
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
|
||||||
if (Number.isNaN(d.getTime())) return '—';
|
if (Number.isNaN(d.getTime())) return '-';
|
||||||
return format(d, 'MMM d, yyyy');
|
return format(d, 'MMM d, yyyy');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
|
|||||||
Primary
|
Primary
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right: archive/restore as a small icon button — destructive
|
{/* Top-right: archive/restore as a small icon button - destructive
|
||||||
action sits out of the primary action flow. */}
|
action sits out of the primary action flow. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||||
{/* Dedup suggestion — only on the create path. Watches the
|
{/* Dedup suggestion - only on the create path. Watches the
|
||||||
live form values for email / phone / name and surfaces
|
live form values for email / phone / name and surfaces
|
||||||
an existing client when one matches. The user can
|
an existing client when one matches. The user can
|
||||||
attach the new interest to that client instead of
|
attach the new interest to that client instead of
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
|
|||||||
}) {
|
}) {
|
||||||
// Pin the most recently selected interest so the drawer stays populated
|
// Pin the most recently selected interest so the drawer stays populated
|
||||||
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
||||||
// after `open=false`). Conditional setState is safe here — the guard
|
// after `open=false`). Conditional setState is safe here - the guard
|
||||||
// ensures it only fires when the prop actually changes to a new row.
|
// ensures it only fires when the prop actually changes to a new row.
|
||||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||||
if (interest && interest !== pinned) setPinned(interest);
|
if (interest && interest !== pinned) setPinned(interest);
|
||||||
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
||||||
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
{/* Pipeline-stepper segmented bar - the same primitive used on the
|
||||||
row card, so the at-a-glance progress hint is consistent
|
row card, so the at-a-glance progress hint is consistent
|
||||||
across surfaces. */}
|
across surfaces. */}
|
||||||
{stage ? (
|
{stage ? (
|
||||||
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Milestones — three sections matching the full interest detail
|
{/* Milestones - three sections matching the full interest detail
|
||||||
page (EOI / Deposit / Contract). Done-state is derived from
|
page (EOI / Deposit / Contract). Done-state is derived from
|
||||||
the pipeline stage so seed data without per-step dates still
|
the pipeline stage so seed data without per-step dates still
|
||||||
renders correctly. The full milestone columns + per-step
|
renders correctly. The full milestone columns + per-step
|
||||||
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Compact key/value pairs — lead category, source, last contact,
|
{/* Compact key/value pairs - lead category, source, last contact,
|
||||||
activity. Each row collapses cleanly when its value is
|
activity. Each row collapses cleanly when its value is
|
||||||
missing so the drawer scales from sparse seed data to full
|
missing so the drawer scales from sparse seed data to full
|
||||||
records without empty placeholders. */}
|
records without empty placeholders. */}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
|||||||
interface PipelineSummaryProps {
|
interface PipelineSummaryProps {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
/**
|
/**
|
||||||
* `hero` — single-line pulse for the detail header (highest active stage only).
|
* `hero` - single-line pulse for the detail header (highest active stage only).
|
||||||
* `panel` — compact list of every active interest, for the Overview tab.
|
* `panel` - compact list of every active interest, for the Overview tab.
|
||||||
*/
|
*/
|
||||||
variant?: 'hero' | 'panel';
|
variant?: 'hero' | 'panel';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ interface ClientTabsOptions {
|
|||||||
tenureType: string;
|
tenureType: string;
|
||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
|
interestCount?: number;
|
||||||
|
noteCount?: number;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -224,6 +226,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
{
|
{
|
||||||
id: 'interests',
|
id: 'interests',
|
||||||
label: 'Interests',
|
label: 'Interests',
|
||||||
|
badge: client.interestCount,
|
||||||
content: <ClientInterestsTab clientId={clientId} />,
|
content: <ClientInterestsTab clientId={clientId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -261,6 +264,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
|
badge: client.noteCount,
|
||||||
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
|
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
|
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{y.hullNumber ?? '—'}</TableCell>
|
<TableCell>{y.hullNumber ?? '-'}</TableCell>
|
||||||
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -225,10 +225,10 @@ function ContactRow({
|
|||||||
|
|
||||||
{/* Bottom / right: tag + actions.
|
{/* Bottom / right: tag + actions.
|
||||||
Two layers of hiding compose here:
|
Two layers of hiding compose here:
|
||||||
(a) phoneEditing — when the phone editor is open, hide the entire
|
(a) phoneEditing - when the phone editor is open, hide the entire
|
||||||
action cluster (tag + star + trash) so the user can focus on
|
action cluster (tag + star + trash) so the user can focus on
|
||||||
the form without chips fighting for space.
|
the form without chips fighting for space.
|
||||||
(b) contact.value — when the value is empty (stale import row,
|
(b) contact.value - when the value is empty (stale import row,
|
||||||
aborted edit), hide just the tag + Make-primary star;
|
aborted edit), hide just the tag + Make-primary star;
|
||||||
neither makes sense without a value. The trash icon stays
|
neither makes sense without a value. The trash icon stays
|
||||||
so the user can clean up the empty entry.
|
so the user can clean up the empty entry.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
||||||
// Clear the dismissed flag when inputs change — the user typed
|
// Clear the dismissed flag when inputs change - the user typed
|
||||||
// something new, so the prior dismissal no longer applies.
|
// something new, so the prior dismissal no longer applies.
|
||||||
setDismissed(false);
|
setDismissed(false);
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
|
|||||||
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
||||||
},
|
},
|
||||||
enabled: hasSomething && !dismissed,
|
enabled: hasSomething && !dismissed,
|
||||||
// Same query is fine to cache for a minute — moves are slow at this layer.
|
// Same query is fine to cache for a minute - moves are slow at this layer.
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
|
|||||||
<p className="text-sm font-semibold leading-tight">
|
<p className="text-sm font-semibold leading-tight">
|
||||||
{isHigh
|
{isHigh
|
||||||
? 'This looks like an existing client'
|
? 'This looks like an existing client'
|
||||||
: 'Possible match — check before creating'}
|
: 'Possible match - check before creating'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Export queued — refresh in ~30 seconds');
|
toast.success('Export queued - refresh in ~30 seconds');
|
||||||
qc.invalidateQueries({ queryKey });
|
qc.invalidateQueries({ queryKey });
|
||||||
setEmailOverride('');
|
setEmailOverride('');
|
||||||
},
|
},
|
||||||
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
|||||||
Email the bundle when ready
|
Email the bundle when ready
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Sends a 7-day signed download link to the client's primary email — or to the
|
Sends a 7-day signed download link to the client's primary email - or to the
|
||||||
override below.
|
override below.
|
||||||
</p>
|
</p>
|
||||||
{emailToClient ? (
|
{emailToClient ? (
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
|
|||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
let msg = err instanceof Error ? err.message : 'Failed to add membership';
|
let msg = err instanceof Error ? err.message : 'Failed to add membership';
|
||||||
// Detect 409 — service returns a "membership already exists" message
|
// Detect 409 - service returns a "membership already exists" message
|
||||||
if (/already exists/i.test(msg)) {
|
if (/already exists/i.test(msg)) {
|
||||||
msg = 'This membership already exists (same client + role + start date).';
|
msg = 'This membership already exists (same client + role + start date).';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function getCompanyColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const value = getValue() as string | null;
|
const value = getValue() as string | null;
|
||||||
if (!value) return <span className="text-muted-foreground">—</span>;
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||||
return <span className="text-sm">{value}</span>;
|
return <span className="text-sm">{value}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -87,7 +87,7 @@ export function getCompanyColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const value = getValue() as string | null;
|
const value = getValue() as string | null;
|
||||||
if (!value) return <span className="text-muted-foreground">—</span>;
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||||
return <span className="text-sm">{value}</span>;
|
return <span className="text-sm">{value}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -98,7 +98,7 @@ export function getCompanyColumns({
|
|||||||
size: 88,
|
size: 88,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const n = row.original.memberCount ?? 0;
|
const n = row.original.memberCount ?? 0;
|
||||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
if (n === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return <Badge variant="secondary">{n}</Badge>;
|
return <Badge variant="secondary">{n}</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,7 +109,7 @@ export function getCompanyColumns({
|
|||||||
size: 88,
|
size: 88,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const n = row.original.yachtCount ?? 0;
|
const n = row.original.yachtCount ?? 0;
|
||||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
if (n === 0) return <span className="text-muted-foreground">-</span>;
|
||||||
return <Badge variant="secondary">{n}</Badge>;
|
return <Badge variant="secondary">{n}</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailHeaderStrip>
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3 flex-wrap">
|
{/* Stack actions below the title block on phone widths; horizontal
|
||||||
|
beside it from sm up. */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:flex-wrap sm:gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CreateCompanyInput) => {
|
mutationFn: async (data: CreateCompanyInput) => {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
// updateCompanySchema omits tagIds — strip them from PATCH body.
|
// updateCompanySchema omits tagIds - strip them from PATCH body.
|
||||||
const { tagIds: _tIds, ...rest } = data;
|
const { tagIds: _tIds, ...rest } = data;
|
||||||
void _tIds;
|
void _tIds;
|
||||||
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
||||||
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
|||||||
value={watch('incorporationCountryIso')}
|
value={watch('incorporationCountryIso')}
|
||||||
onChange={(iso) => {
|
onChange={(iso) => {
|
||||||
setValue('incorporationCountryIso', iso ?? undefined);
|
setValue('incorporationCountryIso', iso ?? undefined);
|
||||||
// Wipe subdivision when country flips — codes are country-scoped.
|
// Wipe subdivision when country flips - codes are country-scoped.
|
||||||
setValue('incorporationSubdivisionIso', undefined);
|
setValue('incorporationSubdivisionIso', undefined);
|
||||||
}}
|
}}
|
||||||
data-testid="company-incorp-country"
|
data-testid="company-incorp-country"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) return '—';
|
if (!value) return '-';
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
||||||
{m.roleDetail ?? '—'}
|
{m.roleDetail ?? '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatDate(m.startDate)}</TableCell>
|
<TableCell>{formatDate(m.startDate)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{m.endDate ? (
|
{m.endDate ? (
|
||||||
formatDate(m.endDate)
|
formatDate(m.endDate)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
|
|||||||
Primary
|
Primary
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
function formatDimensions(y: OwnedYachtRow): string | null {
|
function formatDimensions(y: OwnedYachtRow): string | null {
|
||||||
if (y.lengthFt || y.widthFt) {
|
if (y.lengthFt || y.widthFt) {
|
||||||
const length = y.lengthFt ?? '—';
|
const length = y.lengthFt ?? '-';
|
||||||
const width = y.widthFt ?? '—';
|
const width = y.widthFt ?? '-';
|
||||||
return `${length} × ${width} ft`;
|
return `${length} × ${width} ft`;
|
||||||
}
|
}
|
||||||
if (y.lengthM || y.widthM) {
|
if (y.lengthM || y.widthM) {
|
||||||
const length = y.lengthM ?? '—';
|
const length = y.lengthM ?? '-';
|
||||||
const width = y.widthM ?? '—';
|
const width = y.widthM ?? '-';
|
||||||
return `${length} × ${width} m`;
|
return `${length} × ${width} m`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
|
|||||||
{dims ? (
|
{dims ? (
|
||||||
<span className="text-sm">{dims}</span>
|
<span className="text-sm">{dims}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{y.hullNumber ? (
|
{y.hullNumber ? (
|
||||||
<span className="text-sm">{y.hullNumber}</span>
|
<span className="text-sm">{y.hullNumber}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
|||||||
<InlineCountryField
|
<InlineCountryField
|
||||||
value={company.incorporationCountryIso}
|
value={company.incorporationCountryIso}
|
||||||
onSave={async (iso) => {
|
onSave={async (iso) => {
|
||||||
// Wipe subdivision when country flips — codes are country-scoped.
|
// Wipe subdivision when country flips - codes are country-scoped.
|
||||||
await mutation.mutateAsync({
|
await mutation.mutateAsync({
|
||||||
incorporationCountryIso: iso,
|
incorporationCountryIso: iso,
|
||||||
incorporationSubdivisionIso: null,
|
incorporationSubdivisionIso: null,
|
||||||
@@ -146,7 +146,11 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
|||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Incorporation Date">
|
<EditableRow label="Incorporation Date">
|
||||||
<InlineEditableField
|
<InlineEditableField
|
||||||
value={company.incorporationDate}
|
// The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z")
|
||||||
|
// because Postgres `date` columns are serialized through JSON. Strip
|
||||||
|
// the time portion so the read-only state shows just YYYY-MM-DD,
|
||||||
|
// which is also the format the user types when editing.
|
||||||
|
value={company.incorporationDate ? company.incorporationDate.slice(0, 10) : null}
|
||||||
placeholder="YYYY-MM-DD"
|
placeholder="YYYY-MM-DD"
|
||||||
onSave={save('incorporationDate')}
|
onSave={save('incorporationDate')}
|
||||||
/>
|
/>
|
||||||
@@ -171,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
|||||||
variant="textarea"
|
variant="textarea"
|
||||||
value={company.notes}
|
value={company.notes}
|
||||||
onSave={save('notes')}
|
onSave={save('notes')}
|
||||||
emptyText="No notes — click to add"
|
emptyText="No notes - click to add"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function ActivityFeedInner() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No recent activity yet — your team's actions (interests created, stages changed,
|
No recent activity yet - your team's actions (interests created, stages changed,
|
||||||
invoices sent) will appear here.
|
invoices sent) will appear here.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { usePortContext } from '@/providers/port-provider';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { KpiCardsWithBoundary } from './kpi-cards';
|
import { KpiCardsWithBoundary } from './kpi-cards';
|
||||||
import { ActivityFeed } from './activity-feed';
|
import { ActivityFeed } from './activity-feed';
|
||||||
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
|||||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||||
import { LeadSourceChart } from './lead-source-chart';
|
import { LeadSourceChart } from './lead-source-chart';
|
||||||
import { MyRemindersRail } from './my-reminders-rail';
|
import { MyRemindersRail } from './my-reminders-rail';
|
||||||
|
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
const RANGE_LABELS: Record<DateRange, string> = {
|
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
'7d': 'Last 7 days',
|
'7d': 'Last 7 days',
|
||||||
'30d': 'Last 30 days',
|
'30d': 'Last 30 days',
|
||||||
'90d': 'Last 90 days',
|
'90d': 'Last 90 days',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function rangeLabel(range: DateRange): string {
|
||||||
|
if (isCustomRange(range)) {
|
||||||
|
const fmt: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
};
|
||||||
|
const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
|
||||||
|
const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
|
||||||
|
return `${from} – ${to}`;
|
||||||
|
}
|
||||||
|
return PRESET_LABELS[range];
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardShell() {
|
export function DashboardShell() {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
|
const { currentPort } = usePortContext();
|
||||||
|
const portName = currentPort?.name ?? 'this port';
|
||||||
|
|
||||||
|
// Use a partial query-key prefix (no range segment) for invalidations.
|
||||||
|
// Reading: "any cached analytics result, regardless of range, please
|
||||||
|
// refetch on this event." This avoids any chance that a custom-range
|
||||||
|
// object literal hashes differently than the one stored in the cache,
|
||||||
|
// and keeps the invalidation surface broad enough to refresh whichever
|
||||||
|
// range the user is currently looking at.
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
'interest:stageChanged': [
|
'interest:stageChanged': [
|
||||||
['analytics', 'pipeline_funnel', range],
|
['analytics', 'pipeline_funnel'],
|
||||||
['analytics', 'lead_source_attribution', range],
|
['analytics', 'lead_source_attribution'],
|
||||||
['dashboard', 'kpis'],
|
['dashboard', 'kpis'],
|
||||||
],
|
],
|
||||||
'client:created': [['dashboard', 'kpis']],
|
'client:created': [['dashboard', 'kpis']],
|
||||||
'berth:statusChanged': [
|
'berth:statusChanged': [
|
||||||
['analytics', 'occupancy_timeline', range],
|
['analytics', 'occupancy_timeline'],
|
||||||
['dashboard', 'kpis'],
|
['dashboard', 'kpis'],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -44,8 +69,8 @@ export function DashboardShell() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
eyebrow="Overview"
|
eyebrow="Overview"
|
||||||
description="Live snapshot of your marina activity"
|
description={`Live snapshot of ${portName} activity`}
|
||||||
kpiLine={<span>{RANGE_LABELS[range]}</span>}
|
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +79,12 @@ export function DashboardShell() {
|
|||||||
<KpiCardsWithBoundary />
|
<KpiCardsWithBoundary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
|
{/* `items-start` is critical: without it, the right-column aside is
|
||||||
|
stretched to match the chart column's row height, which forces
|
||||||
|
MyRemindersRail (or any other child with `h-full`) to push later
|
||||||
|
children out of the aside's box and into the rows below where
|
||||||
|
ActivityFeed renders. */}
|
||||||
|
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<PipelineFunnelChart range={range} />
|
<PipelineFunnelChart range={range} />
|
||||||
@@ -70,6 +100,11 @@ export function DashboardShell() {
|
|||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<aside className="min-w-0 space-y-4">
|
<aside className="min-w-0 space-y-4">
|
||||||
|
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
|
||||||
|
isn't configured for this port. */}
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<WebsiteGlanceTile />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<MyRemindersRail />
|
<MyRemindersRail />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Calendar } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
value: DateRange;
|
value: DateRange;
|
||||||
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPTIONS: Array<{ value: DateRange; label: string }> = [
|
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
|
||||||
{ value: 'today', label: 'Today' },
|
{ value: 'today', label: 'Today' },
|
||||||
{ value: '7d', label: '7d' },
|
{ value: '7d', label: '7d' },
|
||||||
{ value: '30d', label: '30d' },
|
{ value: '30d', label: '30d' },
|
||||||
{ value: '90d', label: '90d' },
|
{ value: '90d', label: '90d' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a custom range as a compact button label, e.g. "Apr 14 – May 4".
|
||||||
|
* Same year omits the year on both sides; different years includes both.
|
||||||
|
*/
|
||||||
|
function formatCustom(range: { from: string; to: string }): string {
|
||||||
|
const from = new Date(`${range.from}T00:00:00.000Z`);
|
||||||
|
const to = new Date(`${range.to}T00:00:00.000Z`);
|
||||||
|
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
|
||||||
|
const fmt: Intl.DateTimeFormatOptions = sameYear
|
||||||
|
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
|
||||||
|
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
|
||||||
|
return `${from.toLocaleDateString('en-US', fmt)} – ${to.toLocaleDateString('en-US', fmt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
|
||||||
|
* "to" picker so users can't accidentally pick a future date by leaving the
|
||||||
|
* field empty.
|
||||||
|
*/
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isCustom = isCustomRange(value);
|
||||||
|
|
||||||
|
// Local state for the popover form. Seeded from the current value if it's
|
||||||
|
// already custom, otherwise defaults to a 14-day window ending today.
|
||||||
|
const [draftFrom, setDraftFrom] = useState<string>(() => {
|
||||||
|
if (isCustom) return value.from;
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - 14);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
|
||||||
|
|
||||||
|
const today = todayIso();
|
||||||
|
const draftValid =
|
||||||
|
draftFrom !== '' &&
|
||||||
|
draftTo !== '' &&
|
||||||
|
draftFrom <= draftTo &&
|
||||||
|
draftFrom <= today &&
|
||||||
|
draftTo <= today;
|
||||||
|
|
||||||
|
function applyCustom() {
|
||||||
|
if (!draftValid) return;
|
||||||
|
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{OPTIONS.map((opt) => {
|
{PRESETS.map((opt) => {
|
||||||
const active = opt.value === value;
|
const active = !isCustom && opt.value === value;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Custom range - popover with two date inputs and an Apply button */}
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isCustom}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
|
||||||
|
isCustom
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
data-testid="range-custom"
|
||||||
|
>
|
||||||
|
<Calendar className="h-3 w-3" aria-hidden />
|
||||||
|
{isCustom ? formatCustom(value) : 'Custom'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-[260px] p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Custom range
|
||||||
|
</div>
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="block text-muted-foreground mb-1">From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draftFrom}
|
||||||
|
/* `max` capped at min(draftTo, today). Without the today
|
||||||
|
cap, users could pick a future From, end up with an
|
||||||
|
empty result, and not understand why. */
|
||||||
|
max={draftTo && draftTo < today ? draftTo : today}
|
||||||
|
onChange={(e) => setDraftFrom(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="block text-muted-foreground mb-1">To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draftTo}
|
||||||
|
min={draftFrom || undefined}
|
||||||
|
max={today}
|
||||||
|
onChange={(e) => setDraftTo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import { KPITile } from '@/components/ui/kpi-tile';
|
import { KPITile } from '@/components/ui/kpi-tile';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KpiCards() {
|
export function KpiCards() {
|
||||||
|
// Keying on currentPortId ensures React Query treats a port-resolved fetch
|
||||||
|
// as a different query than the one that fires on first paint when the
|
||||||
|
// store hasn't yet hydrated. Without this, an early null-port fetch could
|
||||||
|
// cache an error and display "-" indefinitely until the staleTime expires.
|
||||||
|
const portId = useUIStore((s) => s.currentPortId);
|
||||||
const { data, isLoading, isError } = useQuery<KpiData>({
|
const { data, isLoading, isError } = useQuery<KpiData>({
|
||||||
queryKey: ['dashboard', 'kpis'],
|
queryKey: ['dashboard', 'kpis', portId],
|
||||||
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
|
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
// Avoid running until we have a port id - gates against the early
|
||||||
|
// unauth/no-port window where the API would return zeroes/errors.
|
||||||
|
enabled: !!portId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -62,22 +71,22 @@ export function KpiCards() {
|
|||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
label: 'Total Clients',
|
label: 'Total Clients',
|
||||||
value: isError ? '—' : String(data?.totalClients ?? 0),
|
value: isError ? '-' : String(data?.totalClients ?? 0),
|
||||||
accent: 'brand',
|
accent: 'brand',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Active Interests',
|
label: 'Active Interests',
|
||||||
value: isError ? '—' : String(data?.activeInterests ?? 0),
|
value: isError ? '-' : String(data?.activeInterests ?? 0),
|
||||||
accent: 'teal',
|
accent: 'teal',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pipeline Value',
|
label: 'Pipeline Value',
|
||||||
value: isError ? '—' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
||||||
accent: 'success',
|
accent: 'success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Occupancy Rate',
|
label: 'Occupancy Rate',
|
||||||
value: isError ? '—' : formatPercent(data?.occupancyRate ?? 0),
|
value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
|
||||||
accent: 'purple',
|
accent: 'purple',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
) : !slices.length ? (
|
) : !slices.length ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No interests in range"
|
title="No interests in range"
|
||||||
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
description="Lights up once new interests are created - tracks where each came from (website, referral, broker)."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Percentage radii + center-anchored chart so the pie scales with
|
// Percentage radii + center-anchored chart so the pie scales with
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
|
|||||||
/**
|
/**
|
||||||
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||||
* to the current user (overdue first, then upcoming). Each item links to its
|
* to the current user (overdue first, then upcoming). Each item links to its
|
||||||
* subject — interest preferred, then client, then the generic entity ref.
|
* subject - interest preferred, then client, then the generic entity ref.
|
||||||
*
|
*
|
||||||
* Limited to 6 items; "View all" routes to /reminders.
|
* Limited to 6 items; "View all" routes to /reminders.
|
||||||
*/
|
*/
|
||||||
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
|
|||||||
return `/${portSlug}/reminders`;
|
return `/${portSlug}/reminders`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
// Natural height - the parent dashboard grid uses `items-start` so the
|
||||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
// aside column no longer forces this rail to fill the chart column's
|
||||||
// tall empty card.
|
// height. Stretching here pushed AlertRail out of the aside's box and
|
||||||
|
// into the territory below where ActivityFeed renders, producing a
|
||||||
|
// visible overlap on tall viewports.
|
||||||
return (
|
return (
|
||||||
<Card className="xl:h-full">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
|
|||||||
</div>
|
</div>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<p className="py-3 text-center text-sm text-muted-foreground">
|
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||||
All caught up — no reminders.
|
All caught up - no reminders.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
|
||||||
LeadSourceAttributionData,
|
LeadSourceAttributionData,
|
||||||
MetricBase,
|
MetricBase,
|
||||||
OccupancyTimelineData,
|
OccupancyTimelineData,
|
||||||
@@ -18,12 +18,27 @@ interface MetricResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DateRange (preset or custom) into the URL query params the
|
||||||
|
* /api/v1/analytics route expects: `range=30d` for presets, or
|
||||||
|
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD` for custom.
|
||||||
|
*/
|
||||||
|
function rangeToQuery(range: DateRange): string {
|
||||||
|
if (isCustomRange(range)) {
|
||||||
|
return `range=custom&from=${range.from}&to=${range.to}`;
|
||||||
|
}
|
||||||
|
return `range=${range}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
||||||
return useQuery<T>({
|
return useQuery<T>({
|
||||||
|
// Stringify custom ranges into the cache key so React Query treats
|
||||||
|
// each {from,to} pair as its own query - otherwise switching dates
|
||||||
|
// would never refetch.
|
||||||
queryKey: ['analytics', metric, range],
|
queryKey: ['analytics', metric, range],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch<MetricResponse<T>>(
|
const res = await apiFetch<MetricResponse<T>>(
|
||||||
`/api/v1/analytics?metric=${metric}&range=${range}`,
|
`/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|||||||
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 }> };
|
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
|
||||||
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
||||||
toast.info(
|
toast.info(
|
||||||
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} — opens in PR8 wizard`,
|
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');
|
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface DocumentListProps {
|
|||||||
interestId?: string;
|
interestId?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
/** Override the default empty state ("No documents yet.") with a contextual
|
/** Override the default empty state ("No documents yet.") with a contextual
|
||||||
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
* CTA - e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||||
emptyState?: React.ReactNode;
|
emptyState?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSignerProgress = (doc: DocumentRow) => {
|
const getSignerProgress = (doc: DocumentRow) => {
|
||||||
if (!doc.signers) return '—';
|
if (!doc.signers) return '-';
|
||||||
const signed = doc.signers.filter((s) => s.status === 'signed').length;
|
const signed = doc.signers.filter((s) => s.status === 'signed').length;
|
||||||
return `${signed}/${doc.signers.length} signed`;
|
return `${signed}/${doc.signers.length} signed`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
|||||||
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '—'}
|
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import {
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
/** Required for the EOI's top paragraph (Section 2) — without these the
|
/** Required for the EOI's top paragraph (Section 2) - without these the
|
||||||
* document is unsignable, so generation is blocked. Yacht and berth fields
|
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||||
* belong to Section 3 and may be left blank. */
|
* belong to Section 3 and may be left blank. */
|
||||||
interface EoiPrerequisites {
|
interface EoiPrerequisites {
|
||||||
hasName: boolean;
|
hasName: boolean;
|
||||||
hasEmail: boolean;
|
hasEmail: boolean;
|
||||||
hasAddress: boolean;
|
hasAddress: boolean;
|
||||||
/** Optional — info-only checks. Generation proceeds without them. */
|
/** Optional - info-only checks. Generation proceeds without them. */
|
||||||
hasYacht: boolean;
|
hasYacht: boolean;
|
||||||
hasBerth: boolean;
|
hasBerth: boolean;
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Optional (Section 3 — left blank if absent)
|
Optional (Section 3 - left blank if absent)
|
||||||
</p>
|
</p>
|
||||||
{OPTIONAL_LABELS.map(({ key, label }) => (
|
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||||
<div key={key} className="flex items-center gap-3 text-sm">
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Amount — prominent */}
|
{/* Amount - prominent */}
|
||||||
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
|
||||||
{amountFormatted}
|
{amountFormatted}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user